Developer documentation
HookQ API
Give your AI agent real-world capabilities. Connect apps once in the dashboard, then call them with a single API key — HookQ handles OAuth, token refresh, and encryption.
/api/v1Auth: Bearer <API_KEY>120 requests / minQuickstart
- Connect Gmail for this walkthrough in Dashboard → Integrations.
- Create a key in Dashboard → API keys and copy the secret (shown once). Select Gmail’s profile and list-message actions for the read-only walkthrough. Choose an expiry; new keys default to read-only. Keys look like
hq_live_…and are stored only as a hash. - Export your key and base URL in your terminal, then discover your tools:
export HOOKQ_API_KEY="hq_live_your_key_here"
export HOOKQ_BASE_URL="http://localhost:3000" # replace with your host
curl -sS "$HOOKQ_BASE_URL/api/v1/tools" \
-H "Authorization: Bearer $HOOKQ_API_KEY"Use the origin of your HookQ deployment, without a trailing slash or /api/v1. The localhost example requires the web app running locally. Use HTTPS for a hosted deployment.
Make your first call
With Gmail connected, this read-only call returns your account profile and uses one action credit.
curl -sS -X POST "$HOOKQ_BASE_URL/api/v1/run" \
-H "Authorization: Bearer $HOOKQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider":"gmail","action":"gmail_get_profile","input":{}}'{
"ok": true,
"result": {
"emailAddress": "you@example.com",
"messagesTotal": 42,
"threadsTotal": 30,
"historyId": "12345"
}
}Illustrative response; your values will differ. Using another app? Find its provider and a read tool in the provider reference. If the call fails, use the error guide.
Authentication & rate limits
Every request sends your key as a Bearer token. Keep it in a server-side environment variable; never commit it or embed it in browser code. A key can access only its selected actions and must satisfy any exact field restrictions. Choose 7, 30 (default), or 90 days, or use Custom to select a date beyond 90 days and up to two years (730 days). Legacy keys without scopes or expiry are rejected and must be replaced. Create separate keys for separate agents, and revoke exposed or unused keys in the dashboard.
Authorization: Bearer hq_live_your_key_hereEach key allows 120 requests per minute per server instance. Responses handled after successful authentication include rate-limit headers:
| X-RateLimit-Limit | Requests allowed per window (120) |
| X-RateLimit-Remaining | Requests left in the current window |
| X-RateLimit-Reset | Unix seconds when the window resets |
Exceeding the limit returns 429. Wait the number of seconds in Retry-After before retrying. Discovery requests count toward this limit too.
List connected integrations
GET /api/v1/integrations — the connected apps permitted by this key.
curl -sS "$HOOKQ_BASE_URL/api/v1/integrations" \
-H "Authorization: Bearer $HOOKQ_API_KEY"{
"integrations": [
{
"provider": "gmail",
"name": "Gmail",
"status": "connected",
"scopes": ["https://www.googleapis.com/auth/gmail.modify"],
"tools": 3,
"connectedAt": "2026-10-11T09:00:00.000Z"
}
]
}Discover tools
GET /api/v1/tools returns tools permitted by this key and their JSON Schema. Add ?provider=slug to filter. Each tool reports connected: true only for an active connection. Disabled, revoked, and error connections report false. An empty tools array means no permitted tools match the provider filter. Each tool also includes the key’s exact field restrictions in constraints.
# All tools
curl -sS "$HOOKQ_BASE_URL/api/v1/tools" \
-H "Authorization: Bearer $HOOKQ_API_KEY"
# Just Gmail tools
curl -sS "$HOOKQ_BASE_URL/api/v1/tools?provider=gmail" \
-H "Authorization: Bearer $HOOKQ_API_KEY"Example excerpt showing one tool from the Gmail response:
{
"tools": [
{
"provider": "gmail",
"name": "gmail_list_messages",
"description": "List recent Gmail messages with sender, subject, date, and snippet. Supports a Gmail search query such as 'is:unread from:alice@acme.com'.",
"write": false,
"connected": true,
"constraints": {},
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Gmail search query (optional)"
},
"max": {
"type": "string",
"description": "Max results (1–25, default 10)"
}
},
"required": [],
"additionalProperties": false
}
}
]
}Run a tool
POST /api/v1/run executes a tool. Write actions (where write: true) require a key with that write action enabled and confirm: true.
| Field | Type | Notes |
|---|---|---|
| provider | string | e.g. gmail, github |
| action | string | tool name, e.g. gmail_send_email |
| input | object | string key/values from the tool schema |
| confirm | boolean | must be true for write actions |
provider and action are required. Omit input only if the tool has no required fields. All input values must be strings, including numbers ("max": "5") and JSON content requested by a tool.
Read (no confirmation needed)
curl -sS -X POST "$HOOKQ_BASE_URL/api/v1/run" \
-H "Authorization: Bearer $HOOKQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "gmail",
"action": "gmail_list_messages",
"input": { "query": "is:unread", "max": "5" }
}'For a write, use a key with read-only disabled and the specific write action selected. The caller supplies confirm: true; this is not independent proof of human approval. Obtain any needed user consent before sending the request. The following command sends a real email when configured.
Write (confirmation required)
curl -sS -X POST "$HOOKQ_BASE_URL/api/v1/run" \
-H "Authorization: Bearer $HOOKQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "gmail",
"action": "gmail_send_email",
"input": {
"to": "someone@example.com",
"subject": "Hello from HookQ",
"body": "Sent by my agent."
},
"confirm": true
}'Success response
{ "ok": true, "result": { "ok": true, "id": "1920abc...", "threadId": "1920abc..." } }Error handling
Expected API errors return { "error": { "code": "...", "message": "..." } } with an HTTP status:
| Status | Code | When |
|---|---|---|
| 400 | invalid_json / invalid_request | Malformed body or missing fields |
| 401 | unauthorized | Check the key and deployment URL. Expired, revoked, and legacy unrestricted keys are rejected. |
| 402 | quota_exceeded | No actions left, or billing blocks execution. Check Dashboard → Billing. |
| 403 | scope_denied | Choose a permitted action and satisfy the key’s field restrictions; read-only keys cannot request writes. |
| 403 | connection_disabled | Re-enable the connection in the dashboard or upgrade your plan. |
| 404 | unknown_action | Copy the exact provider and action names from GET /tools. |
| 409 | connection_changed | The connection changed during execution. Review the connection and retry. |
| 409 | not_connected | Connect the provider in Dashboard → Integrations. |
| 428 | confirmation_required | A permitted write requires confirm: true from the caller. |
| 429 | rate_limited | Wait the Retry-After duration in seconds. |
| 502 | provider_error | Inspect the message: tool inputs, provider permissions, or an expired connection may be responsible. |
Validate inputs against the discovered schema. Some tool-level validation errors currently return 502. For provider authentication errors, reconnect the app. Handle unexpected non-JSON responses and network failures too.
Retry writes carefully. A timeout or lost response does not prove that a write failed. Check the destination app before repeating a send, create, or update operation; the API has no idempotency-key support.
Only successful operations consume an action. Failed or unconfirmed calls don't count against your monthly allowance. Successful reads consume credits too. Listing integrations and discovering tools consume no action credits. View remaining credits in Dashboard → Billing.
Code examples
Use the environment variables from the quickstart. These examples only read data and require the corresponding app to be connected and the action selected on your key. For writes, obtain any needed consent and supply confirm: true.
JavaScript / Node (fetch)
Save as example.mjs and run node example.mjs with Node 20 or newer.
const BASE_URL = process.env.HOOKQ_BASE_URL ?? "http://localhost:3000";
const API_KEY = process.env.HOOKQ_API_KEY;
if (!API_KEY) throw new Error("Set HOOKQ_API_KEY first");
async function run(provider, action, input = {}, confirm = false) {
const res = await fetch(`${BASE_URL}/api/v1/run`, {
method: "POST",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ provider, action, input, confirm }),
});
const data = await res.json().catch(() => {
throw new Error(`HTTP ${res.status}: non-JSON response`);
});
if (!res.ok) throw new Error(`${data.error?.code ?? res.status}: ${data.error?.message ?? res.statusText}`);
return data.result;
}
// List unread Gmail messages
const messages = await run("gmail", "gmail_list_messages", { query: "is:unread", max: "5" });
console.log(messages);
Python (requests)
Install requests in your Python environment with python -m pip install requests. Save as example.py and run python example.py.
import os
import requests
BASE_URL = os.environ.get("HOOKQ_BASE_URL", "http://localhost:3000")
API_KEY = os.environ["HOOKQ_API_KEY"]
def run(provider, action, input=None, confirm=False):
payload = {"provider": provider, "action": action, "input": input or {}}
payload["confirm"] = confirm
res = requests.post(
f"{BASE_URL}/api/v1/run",
headers={"Authorization": f"Bearer {API_KEY}"},
json=payload,
timeout=30,
)
try:
data = res.json()
except ValueError:
raise RuntimeError(f"HTTP {res.status_code}: non-JSON response") from None
if not res.ok:
error = data.get("error", {})
raise RuntimeError(f"{error.get('code', res.status_code)}: {error.get('message', res.reason)}")
return data["result"]
# List your GitHub repos
repos = run("github", "github_list_repos", {"max": "5"})
print(repos)
Providers & tools
Discover tools and schemas live via GET /api/v1/tools. Providers currently exposing actions:
GitHub 13 tools · github
github_get_me
Read
Get the authenticated GitHub user's profile.
No input fields.
github_list_repos
Read
List the authenticated user's repositories, most recently updated first.
- max string · optional
- Max results (1–30, default 10)
github_list_issues
Read
List open issues for a repository.
- repo string · required
- Repository (owner/repo)
- state string · optional
- State: open, closed, or all (default open)
github_create_issue
Write · confirmation required
Create an issue in a repository.
- repo string · required
- Repository (owner/repo)
- title string · required
- Issue title
- body string · optional
- Issue body
github_create_pr
Write · confirmation required
Open a pull request from a head branch into a base branch.
- repo string · required
- Repository (owner/repo)
- head string · required
- Head branch (changes to merge)
- base string · required
- Base branch (merge target)
- title string · required
- Pull request title
- body string · optional
- Description
github_comment_on_pr
Write · confirmation required
Add a comment to a pull request or issue by its number. PRs and issues share the same comment endpoint.
- repo string · required
- Repository (owner/repo)
- number string · required
- PR or issue number
- body string · required
- Comment
github_search_code
Read
Search code across GitHub. Supports qualifiers such as 'repo:owner/name' or 'language:ts'.
- query string · required
- Search query
github_list_notifications
Read
List the authenticated user's unread GitHub notifications.
No input fields.
github_list_prs
Read
List pull requests for a repository.
- repo string · required
- Repository (owner/repo)
- state string · optional
- State: open, closed, or all (default open)
github_get_pr
Read
Get details for a pull request, including merge state and change counts.
- repo string · required
- Repository (owner/repo)
- number string · required
- Pull request number
github_list_pr_files
Read
List the files changed in a pull request with per-file diff stats and patch hunks.
- repo string · required
- Repository (owner/repo)
- number string · required
- Pull request number
github_merge_pr
Write · confirmation required
Merge a pull request. Method is one of merge, squash, or rebase.
- repo string · required
- Repository (owner/repo)
- number string · required
- Pull request number
- method string · optional
- Merge method: merge, squash, or rebase (default merge)
github_request_reviewers
Write · confirmation required
Request reviewers on a pull request by their GitHub usernames.
- repo string · required
- Repository (owner/repo)
- number string · required
- Pull request number
- reviewers string · required
- Reviewer usernames (comma-separated)
Gmail 3 tools · gmail
gmail_get_profile
Read
Get the authenticated Gmail account's profile: email address and message/thread counts.
No input fields.
gmail_list_messages
Read
List recent Gmail messages with sender, subject, date, and snippet. Supports a Gmail search query such as 'is:unread from:alice@acme.com'.
- query string · optional
- Gmail search query (optional)
- max string · optional
- Max results (1–25, default 10)
gmail_send_email
Write · confirmation required
Send an email from the authenticated Gmail account.
- to string · required
- Recipient email
- subject string · required
- Subject
- body string · required
- Body
Google Calendar 4 tools · google-calendar
google_calendar_list_calendars
Read
List the calendars the authenticated user can access.
No input fields.
google_calendar_list_events
Read
List upcoming events on a calendar. Defaults to the primary calendar and events from now onward.
- calendar_id string · optional
- Calendar ID (default 'primary')
- query string · optional
- Text search (optional)
- max string · optional
- Max results (1–50, default 10)
google_calendar_create_event
Write · confirmation required
Create a calendar event. Start and end must be RFC3339 datetimes, e.g. 2026-10-05T14:00:00-07:00.
- calendar_id string · optional
- Calendar ID (default 'primary')
- summary string · required
- Title
- start string · required
- Start (RFC3339)
- end string · required
- End (RFC3339)
- description string · optional
- Description
- location string · optional
- Location
- attendees string · optional
- Attendee emails (comma-separated)
google_calendar_delete_event
Write · confirmation required
Delete an event from a calendar by its ID.
- calendar_id string · optional
- Calendar ID (default 'primary')
- event_id string · required
- Event ID
Google Drive 5 tools · google-drive
google_drive_list_files
Read
List or search files. A plain term is treated as full-text search; a raw Drive query (e.g. "mimeType='application/pdf'") is passed through.
- query string · optional
- Search term or raw Drive query (optional)
- max string · optional
- Max results (1–50, default 15)
google_drive_get_file
Read
Get a file's metadata by its ID.
- file_id string · required
- File ID
google_drive_read_text_file
Read
Download the text content of a plain-text file (text, markdown, CSV, JSON, etc.) by its ID.
- file_id string · required
- File ID
google_drive_create_folder
Write · confirmation required
Create a folder, optionally inside a parent folder.
- name string · required
- Folder name
- parent_id string · optional
- Parent folder ID (optional)
google_drive_upload_text
Write · confirmation required
Create a text file with the given content, optionally inside a parent folder.
- name string · required
- File name
- content string · required
- File content
- mime_type string · optional
- MIME type (default text/plain)
- parent_id string · optional
- Parent folder ID (optional)
Google Sheets 4 tools · google-sheets
google_sheets_get_info
Read
Get a spreadsheet's title and the names of its sheets/tabs. Use the ID from the sheet URL.
- spreadsheet_id string · required
- Spreadsheet ID
google_sheets_get_values
Read
Read a range of cells, e.g. 'Sheet1!A1:D20'. Returns a 2D array of values.
- spreadsheet_id string · required
- Spreadsheet ID
- range string · required
- A1 range
google_sheets_append_row
Write · confirmation required
Append a row to a sheet. 'values' is a comma-separated row or a JSON array of cells/rows.
- spreadsheet_id string · required
- Spreadsheet ID
- range string · required
- Target range or sheet name
- values string · required
- Row values (comma-separated or JSON array)
google_sheets_update_values
Write · confirmation required
Overwrite a range with values. 'values' is a comma-separated row or a JSON array of cells/rows.
- spreadsheet_id string · required
- Spreadsheet ID
- range string · required
- A1 range to write
- values string · required
- Values (comma-separated or JSON array)
LinkedIn 2 tools · linkedin
linkedin_get_my_info
Read
Get the authenticated LinkedIn member's profile (name, email, id).
No input fields.
linkedin_create_post
Write · confirmation required
Publish a text post on LinkedIn as the authenticated member.
- text string · required
- Post text
- visibility string · optional
- Visibility (PUBLIC or CONNECTIONS)
Notion 5 tools · notion
notion_search
Read
Search pages and databases the integration has access to. Filter by type with 'page' or 'database'.
- query string · optional
- Search text (optional)
- type string · optional
- Filter: page, database, or all (default all)
notion_get_page
Read
Get a page's metadata (title, URL, timestamps) by its ID.
- page_id string · required
- Page ID
notion_query_database
Read
List pages (rows) in a database by its ID.
- database_id string · required
- Database ID
notion_create_page
Write · confirmation required
Create a page under a parent page, with a title and optional body text (one paragraph per line).
- parent_id string · required
- Parent page ID
- title string · required
- Page title
- content string · optional
- Body text (optional)
notion_append_text
Write · confirmation required
Append paragraphs of text to a page (one paragraph per line).
- page_id string · required
- Page ID
- content string · required
- Text to append
Slack 4 tools · slack
slack_auth_test
Read
Verify the Slack connection and return the authenticated workspace and user/bot identity.
No input fields.
slack_list_channels
Read
List public channels in the workspace with their IDs, names, and member counts.
- max string · optional
- Max results (1–200, default 100)
slack_get_channel_history
Read
Get recent messages from a channel by its ID.
- channel string · required
- Channel ID
- max string · optional
- Max messages (1–50, default 20)
slack_send_message
Write · confirmation required
Post a message to a Slack channel. The channel can be an ID or a #channel name; the bot must be a member.
- channel string · required
- Channel (ID or #name)
- text string · required
- Message text
Telegram 3 tools · telegram
telegram_get_me
Read
Get information about the connected Telegram bot.
No input fields.
telegram_get_updates
Read
Get recent updates received by the bot. Useful to discover chat IDs of users who have messaged it.
No input fields.
telegram_send_message
Write · confirmation required
Send a text message from the bot to a chat. Falls back to the connection's default chat ID when chat_id is omitted.
- chat_id string · optional
- Chat ID (defaults to the connection's default)
- text string · required
- Message text
This reference is generated from the same action registry as the API. Only providers with executable tools appear here. Connect any of these from Dashboard → Integrations. A call returns not_connected (409) until its provider is connected.
Use with an MCP client
HookQ exposes three MCP tools backed by the same API: hookq_list_integrations, hookq_list_tools, and hookq_run. The same scope, confirmation, billing, and rate-limit rules apply. Two transports are available:
| Transport | Use when | Needs |
|---|---|---|
| Remote (Streamable HTTP) | Cloud agents such as Grok, or any client that takes an MCP URL | Only the URL and an API key |
| Local (stdio) | Desktop clients that launch a command | A local checkout of this repository |
Remote MCP (HTTP)
The endpoint is POST {HOOKQ_BASE_URL}/api/mcp. Send your API key in the Authorization header as Bearer hq_live_…. A bare hq_live_… value is also accepted for hosts that do not add a scheme. The server is stateless and returns JSON responses. It has no sessions and no standalone SSE stream, so GET and DELETE return 405. Each MCP request counts toward the key's 120 requests per minute.
Check the endpoint with a tools/list request:
curl -sS -X POST "$HOOKQ_BASE_URL/api/mcp" \
-H "Authorization: Bearer $HOOKQ_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'For clients that accept an MCP URL with headers, merge this entry into the existing configuration. The format varies by client.
{
"mcpServers": {
"hookq": {
"url": "https://your-hookq-host/api/mcp",
"headers": { "Authorization": "Bearer hq_live_your_key_here" }
}
}
}Cloud-hosted agents need a public HTTPS URL. Services such as xAI call your endpoint from their servers and cannot reach localhost. Deploy HookQ, or expose a local dev server temporarily with a tunnel such as cloudflared tunnel --url http://localhost:3000 and use the printed https://…trycloudflare.com origin. Anyone with the URL still needs a valid key. Stop the tunnel when you are done.
Local MCP (stdio)
You need a local checkout of this repository with its npm dependencies installed and Node/npm available to your MCP client. The HookQ web app must be running separately, either locally or at your hosted base URL.
To launch manually from the repository directory:
HOOKQ_API_KEY=hq_live_your_key_here \
HOOKQ_BASE_URL=http://localhost:3000 \
npm run --silent mcpFor clients that use an mcpServers configuration, merge this entry into the existing configuration. Replace the absolute repository path, key, and base URL, then restart your client. Other clients may use a different configuration format.
{
"mcpServers": {
"hookq": {
"command": "npm",
"args": ["--prefix", "/absolute/path/to/connectQ", "run", "--silent", "mcp"],
"env": {
"HOOKQ_API_KEY": "hq_live_your_key_here",
"HOOKQ_BASE_URL": "http://localhost:3000"
}
}
}
}Waiting silently in a terminal is normal for a stdio server. If the client cannot start it, check the absolute repository path and Node/npm availability. If tools cannot reach HookQ, check that the web app is running at HOOKQ_BASE_URL.
Hermes Agent (Nous Research)
Hermes Agent supports both MCP transports. Use the remote endpoint unless you specifically want Hermes to launch a local process.
Prerequisites
- HookQ web app running (locally with
npm run dev, or hosted). Hermes runs on your machine, sohttp://localhost:3000works without a tunnel. - At least one app connected and an API key that selects the actions Hermes may use. Start with a read-only key.
1. Install Hermes and pick a model
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
hermes modelOn native Windows, use iex (irm https://hermes-agent.nousresearch.com/install.ps1) in PowerShell.
2. Add HookQ to ~/.hermes/config.yaml
Merge this under the top-level mcp_servers key, replacing the URL and key:
mcp_servers:
hookq:
url: "http://localhost:3000/api/mcp"
headers:
Authorization: "Bearer hq_live_your_key_here"
timeout: 60Treat config.yaml as a secret file while it holds your key, and keep it out of version control. For a hosted deployment, use its HTTPS origin in url.
3. Verify and use
hermes mcp test hookq # should list the three HookQ tools
hermes # start a chatHermes registers the tools as mcp_hookq_hookq_list_integrations, mcp_hookq_hookq_list_tools, and mcp_hookq_hookq_run. If Hermes is already running, type /reload-mcp after editing the config. Try a prompt such as:
List my connected HookQ apps, then show my 5 most recent unread Gmail messages.Writes: the model sets confirm: true itself, so Hermes does not ask you before a permitted write runs. Enable write actions on the key only for actions you are comfortable letting the agent perform, or tell Hermes to ask you before any write. To hide a HookQ tool from Hermes, add tools: { exclude: [hookq_run] } to the server entry.
Alternative: launch the stdio server from Hermes
Requires a local checkout of this repository with npm install done. Hermes passes only the configured env plus a small baseline to stdio servers, so use absolute paths and set PATH. Run these from the repository directory to find your values:
command -v npm # e.g. /opt/homebrew/bin/npm
dirname "$(command -v node)" # e.g. /opt/homebrew/bin
pwd # e.g. /Users/you/connectQmcp_servers:
hookq:
command: "/opt/homebrew/bin/npm"
args: ["--prefix", "/Users/you/connectQ", "run", "--silent", "mcp"]
env:
HOOKQ_API_KEY: "hq_live_your_key_here"
HOOKQ_BASE_URL: "http://localhost:3000"
PATH: "/opt/homebrew/bin:/usr/bin:/bin"
timeout: 60spawn npm ENOENT or env: node: No such file or directory means the command or PATH is wrong.
Troubleshooting: hermes logs --level debug shows connection errors. A 401 or unauthorized result means the key is wrong, expired, or revoked. A connection refused error means HookQ is not running at the configured URL.
Grok agent (xAI API)
Grok can use HookQ in two ways. Remote MCP is the shortest setup: xAI connects to /api/mcp and runs the tool calls itself. Function calling runs tool calls in your own script, so you can approve each write before it happens.
Prerequisites
- Python 3.9 or newer.
- An xAI API key from console.x.ai.
- HookQ running, at least one app connected, and a HookQ API key with the actions Grok may use selected.
mkdir grok-hookq && cd grok-hookq
python3 -m venv .venv && source .venv/bin/activate
python -m pip install openai requests
export XAI_API_KEY="xai-your_key_here"
export HOOKQ_API_KEY="hq_live_your_key_here"
# Optional: export GROK_MODEL="grok-4.7"Option A: Remote MCP
xAI's servers call HookQ, so HOOKQ_MCP_URL must be a public HTTPS URL. For local development, start a tunnel as described in Remote MCP.
export HOOKQ_MCP_URL="https://your-hookq-host/api/mcp"Save as grok_mcp.py:
import os
import sys
from openai import OpenAI
grok = OpenAI(api_key=os.environ["XAI_API_KEY"], base_url="https://api.x.ai/v1")
prompt = " ".join(sys.argv[1:]) or "List my connected HookQ apps."
response = grok.responses.create(
model=os.environ.get("GROK_MODEL", "grok-4.7"),
input=[{"role": "user", "content": prompt}],
tools=[{
"type": "mcp",
"server_url": os.environ["HOOKQ_MCP_URL"],
"server_label": "hookq",
"authorization": os.environ["HOOKQ_API_KEY"],
}],
)
print(response.output_text)
python grok_mcp.py "List my connected HookQ apps, then get my Gmail profile."No per-call approval. xAI's remote MCP does not support approval prompts, so Grok can run any write the key permits and set confirm: true itself. Use a read-only key here, or use Option B for writes.
Option B: Function calling with write approval
This terminal agent discovers your HookQ tools over /api/v1, passes them to Grok as functions, and asks you before any write runs. It works with http://localhost:3000 because your machine makes the HookQ calls.
export HOOKQ_BASE_URL="http://localhost:3000"Save as grok_agent.py:
import json
import os
import requests
from openai import OpenAI
HOOKQ_BASE_URL = os.environ.get("HOOKQ_BASE_URL", "http://localhost:3000").rstrip("/")
HOOKQ_HEADERS = {"Authorization": f"Bearer {os.environ['HOOKQ_API_KEY']}"}
MODEL = os.environ.get("GROK_MODEL", "grok-4.7")
MAX_TOOL_ROUNDS = 10
grok = OpenAI(api_key=os.environ["XAI_API_KEY"], base_url="https://api.x.ai/v1")
def hookq(method, path, **kwargs):
res = requests.request(method, f"{HOOKQ_BASE_URL}{path}", headers=HOOKQ_HEADERS, timeout=30, **kwargs)
try:
return res.status_code, res.json()
except ValueError:
return res.status_code, {"error": {"code": str(res.status_code), "message": "non-JSON response"}}
# 1. Discover the tools this key may use, keeping only connected apps.
status, data = hookq("GET", "/api/v1/tools")
if status != 200:
raise SystemExit(f"Tool discovery failed ({status}): {data}")
catalog = {t["name"]: t for t in data["tools"] if t["connected"]}
if not catalog:
raise SystemExit("No connected tools. Connect an app and select its actions on your API key.")
# 2. Expose each HookQ tool to Grok as a function.
tools = [
{
"type": "function",
"name": t["name"],
"description": ("[WRITE - asks the user first] " if t["write"] else "") + t["description"],
"parameters": t["inputSchema"],
}
for t in catalog.values()
]
# 3. Execute a function call through HookQ. Writes need a "y" from you.
def run_tool(name, arguments):
tool = catalog.get(name)
if tool is None:
return {"error": {"code": "unknown_action", "message": f"No tool named {name}"}}
try:
args = json.loads(arguments or "{}")
except json.JSONDecodeError:
return {"error": {"code": "invalid_arguments", "message": "Arguments were not valid JSON"}}
args = {key: str(value) for key, value in args.items()} # HookQ inputs are strings
confirm = False
if tool["write"]:
print(f"\nGrok wants to run {name} ({tool['provider']}) with:\n{json.dumps(args, indent=2)}")
if input("Allow this write? [y/N] ").strip().lower() != "y":
return {"error": {"code": "user_declined", "message": "The user declined this action."}}
confirm = True
_, result = hookq("POST", "/api/v1/run", json={
"provider": tool["provider"], "action": name, "input": args, "confirm": confirm,
})
return result
# 4. Agent loop: let Grok call tools until it answers in text.
def ask(prompt, previous_id=None):
response = grok.responses.create(
model=MODEL, tools=tools, previous_response_id=previous_id,
input=[{"role": "user", "content": prompt}],
)
for _ in range(MAX_TOOL_ROUNDS):
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
return response
outputs = [
{"type": "function_call_output", "call_id": call.call_id,
"output": json.dumps(run_tool(call.name, call.arguments))}
for call in calls
]
response = grok.responses.create(
model=MODEL, tools=tools, previous_response_id=response.id, input=outputs,
)
raise RuntimeError(f"Stopped after {MAX_TOOL_ROUNDS} tool rounds")
print(f"Grok + HookQ ready with {len(tools)} tools. Ctrl+C to quit.")
last_id = None
while True:
prompt = input("\nYou: ").strip()
if not prompt:
continue
response = ask(prompt, last_id)
last_id = response.id
print(f"\nGrok: {response.output_text}")
python grok_agent.pyGrok + HookQ ready with 3 tools. Ctrl+C to quit.
You: Summarize my 5 most recent unread emails.The tool count depends on your key and connected apps. Grok sees only connected tools the key permits. Write tools are labelled so Grok knows you will be asked. Every successful call uses one action credit, as described in error handling.
Troubleshooting: Tool discovery failed (401) means the HookQ key or base URL is wrong. No connected tools means the key selects no actions for a connected app. If xAI cannot reach the MCP server, check that HOOKQ_MCP_URL opens from outside your network. An xAI model not found error means you should set GROK_MODEL to a model listed in your xAI console.
