HookQ

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.

Base: /api/v1Auth: Bearer <API_KEY>120 requests / min

Quickstart

  1. Connect Gmail for this walkthrough in Dashboard → Integrations.
  2. 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.
  3. Export your key and base URL in your terminal, then discover your tools:
bash

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.

bash

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":{}}'
json

{
  "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.

http

Authorization: Bearer hq_live_your_key_here

Each key allows 120 requests per minute per server instance. Responses handled after successful authentication include rate-limit headers:

X-RateLimit-LimitRequests allowed per window (120)
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetUnix 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.

bash

curl -sS "$HOOKQ_BASE_URL/api/v1/integrations" \
  -H "Authorization: Bearer $HOOKQ_API_KEY"
json

{
  "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.

bash

# 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:

json

{
  "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.

FieldTypeNotes
providerstringe.g. gmail, github
actionstringtool name, e.g. gmail_send_email
inputobjectstring key/values from the tool schema
confirmbooleanmust 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)

bash

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)

bash

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

json

{ "ok": true, "result": { "ok": true, "id": "1920abc...", "threadId": "1920abc..." } }

Error handling

Expected API errors return { "error": { "code": "...", "message": "..." } } with an HTTP status:

StatusCodeWhen
400invalid_json / invalid_requestMalformed body or missing fields
401unauthorizedCheck the key and deployment URL. Expired, revoked, and legacy unrestricted keys are rejected.
402quota_exceededNo actions left, or billing blocks execution. Check Dashboard → Billing.
403scope_deniedChoose a permitted action and satisfy the key’s field restrictions; read-only keys cannot request writes.
403connection_disabledRe-enable the connection in the dashboard or upgrade your plan.
404unknown_actionCopy the exact provider and action names from GET /tools.
409connection_changedThe connection changed during execution. Review the connection and retry.
409not_connectedConnect the provider in Dashboard → Integrations.
428confirmation_requiredA permitted write requires confirm: true from the caller.
429rate_limitedWait the Retry-After duration in seconds.
502provider_errorInspect 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.

javascript

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.

python

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:

TransportUse whenNeeds
Remote (Streamable HTTP)Cloud agents such as Grok, or any client that takes an MCP URLOnly the URL and an API key
Local (stdio)Desktop clients that launch a commandA 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:

bash

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.

json

{
  "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:

bash

HOOKQ_API_KEY=hq_live_your_key_here \
HOOKQ_BASE_URL=http://localhost:3000 \
npm run --silent mcp

For 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.

json

{
  "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, so http://localhost:3000 works 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

bash

curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
hermes model

On 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:

yaml

mcp_servers:
  hookq:
    url: "http://localhost:3000/api/mcp"
    headers:
      Authorization: "Bearer hq_live_your_key_here"
    timeout: 60

Treat 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

bash

hermes mcp test hookq   # should list the three HookQ tools
hermes                     # start a chat

Hermes 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:

text

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:

bash

command -v npm                 # e.g. /opt/homebrew/bin/npm
dirname "$(command -v node)"   # e.g. /opt/homebrew/bin
pwd                            # e.g. /Users/you/connectQ
yaml

mcp_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: 60

spawn 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.
bash

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.

bash

export HOOKQ_MCP_URL="https://your-hookq-host/api/mcp"

Save as grok_mcp.py:

python

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)
bash

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.

bash

export HOOKQ_BASE_URL="http://localhost:3000"

Save as grok_agent.py:

python

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}")
bash

python grok_agent.py
text

Grok + 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.