OpenClaw Tools Invoke API Guide: One Tool Call Over HTTP, No Model Turn, and the 404 That Means Policy
POST /tools/invoke runs one OpenClaw tool directly through the Gateway, with no model and no agent turn, so use it when a script already knows which tool and which arguments it wants. It is always enabled, a Gateway token or password grants full operator access on it, and a tool blocked by policy comes back as a 404. When the request needs judgment instead of a fixed call, use /hooks/agent or /v1/chat/completions.
You have a cron script that needs the list of live sessions every ten minutes. Right now it asks the agent, in English, to call sessions_list and paste the result back. That costs a model turn, takes long enough that you added a retry, and once a week the agent decides to summarize the list instead of returning it. The tool was never the slow part.
I worked from the official Tools invoke API page and the OpenAI chat completions page as published on November 4, 2026. Defaults, status codes and the deny list below come from those pages. Where the docs leave a gap, I say so.
What the endpoint actually is
It lives on the same port as the Gateway, since WebSocket and HTTP share it: http://<gateway-host>:<port>/tools/invoke, which is 18789 unless you changed it. The default request body limit is 2 MB. There is no enabled flag to flip, which surprised me more than anything else on the page, because the OpenAI-compatible surface next to it ships disabled and you have to opt in.
That means if your Gateway has a token, this endpoint is already live for anyone holding it.
Four ways to call into a Gateway over HTTP or MCP
| Surface | Off by default? | What runs | Credential | Best for | Key trade-off |
|---|---|---|---|---|---|
POST /tools/invoke | No, always on | One tool call, no model | Gateway token or password (full operator) | Scripts that already know the tool and arguments | No per-call approval prompts, and exec etc. are denied unless you un-deny them |
POST /hooks/agent | Yes, needs hooks.enabled | A separate, safety-wrapped agent run | Separate hook token | Third-party payloads (CI, monitoring, forms) | You pay for a model turn and the result is whatever the agent decides |
POST /v1/chat/completions | Yes, needs gateway.http.endpoints.chatCompletions.enabled | A normal Gateway agent run, same path as openclaw agent | Gateway token or password (full operator) | Existing OpenAI SDK clients | The model field names an agent, not a provider model |
openclaw attach | Grant per run | Claude Code on one session over MCP | Session-scoped grant, 1 hour by default | A human driving tools interactively | MCP URL is Gateway loopback, so it runs on the Gateway host or through a tunnel |
The webhook row is covered in depth in the webhooks guide, and the attach row in the attach guide.
The smallest working call
curl -sS http://127.0.0.1:18789/tools/invoke \
-H 'Authorization: Bearer <gateway-token>' \
-H 'Content-Type: application/json' \
-d '{
"tool": "sessions_list",
"action": "json",
"args": {}
}'A success returns { ok: true, result }. Errors come back as { ok: false, error: { type, message } }, and a 500 carries a sanitized message, so the detail you want is in the Gateway log, not the response. The logging guide shows how to tail it.
Request fields, and the two that lie a little
toolornameis required. Send both andnamewins.actiongets copied intoargs.action, but only if the tool's schema has anactionproperty andargsdidn't already set one.argsholds the tool's own arguments.sessionKeypicks the target session. Omit it or send"main"and you getagent:<agentId>:main(orglobalin global session scope).agentIdresolves the session for that agent, and returns a 400 if it conflicts with asessionKeythat already belongs to another agent.idempotencyKeyderives a stable tool-call id.dryRunis reserved and currently ignored.
Read that last one twice. "dryRun": true appears in the official request example, and it does nothing, so a test script that sets it against a tool that writes will write. I would strip it from anything I copy out of the docs until it ships.
The second trap is quieter. If you set a custom session.mainKey, the endpoint ignores it and still resolves "main" to agent:<agentId>:main. Pass the full session key when it matters.
And idempotencyKey only promises a stable call id. The page does not say a repeated key skips a second execution, so I wouldn't retry a non-idempotent tool on the strength of it.
A token here is an owner credential
With gateway.auth.mode set to token or password, a bearer header proves you hold the shared secret, and the Gateway hands you the full default operator scope set: operator.admin, operator.approvals, operator.pairing, operator.read, operator.talk.secrets and operator.write. Sending a narrower x-openclaw-scopes header changes nothing. Direct tool calls also count as owner-sender turns.
Narrower scopes only work in identity-bearing modes: trusted-proxy, or none on private ingress. There the header is honored, and a caller loses owner semantics only by narrowing scopes and leaving out operator.admin. A same-host proxy also needs gateway.auth.trustedProxy.allowLoopback = true.
My rule: the Gateway token never leaves the Gateway host. If a second machine needs to call tools, give it a tailnet path (the Tailscale guide covers that) and accept that it now holds the keys to everything. The docs say the same thing in plainer terms. Keep the endpoint off the public internet, and if you need separation between trust levels, run separate Gateways, ideally on separate OS users or hosts.
Configure gateway.auth.rateLimit and repeated auth failures get a 429 with Retry-After.
The default HTTP deny list
These twelve tools are blocked on /tools/invoke even when session policy allows them.
| Tool | Why it is denied over HTTP |
|---|---|
exec, spawn, shell | Command execution and child processes (RCE surface) |
fs_write, fs_delete, fs_move | Arbitrary file mutation, deletion or rename on the host |
apply_patch | Patches can rewrite arbitrary files |
sessions_spawn | Spawning agents remotely is RCE |
sessions_send | Cross-session message injection |
cron | Persistent automation control plane (also owner-only) |
gateway | Stops reconfiguring the Gateway over HTTP (also owner-only) |
nodes | Node relay can reach system.run on paired hosts (also owner-only) |
gateway: {
tools: {
deny: ["browser"], // block more over HTTP
allow: ["gateway"], // un-deny for owner/admin callers
},
}gateway.tools.allow is an exposure override and grants no new scopes. In identity-bearing modes, cron, gateway and nodes stay out of reach for anyone without operator.admin, listed or not.
I would never put exec in that allow list. /tools/invoke adds no per-call approval prompt, because exec approvals are operator guardrails rather than an authorization boundary for this endpoint, so the exec approvals you set up for chat won't fire. Denying write, edit and apply_patch doesn't make a reachable shell read-only either. If a script truly needs a command, put it on a node with an allowlist and leave nodes denied here anyway.
A 404 usually means policy, not a typo
Tool availability runs through the same chain your agents use: tools.profile and tools.byProvider.profile, tools.allow, per-agent agents.<id>.tools.allow, group policies when the session key maps to a group or channel, and subagent policy for a subagent session key. Anything filtered out returns 404, the same code as a tool that doesn't exist. So rechecking the spelling, the usual first instinct, is usually wasted time.
A 403 is different: the call reached policy enforcement and was blocked, sometimes with requiresApproval in the error. The page doesn't say which of the two codes the HTTP deny list itself produces. Treat both as "not reachable from here" and check the allow and deny config first.
Group policy needs context to resolve, and an HTTP call has none unless you send it. Four optional headers supply it: x-openclaw-message-channel (for example slack), x-openclaw-account-id when you run several accounts, and x-openclaw-message-to plus x-openclaw-thread-id for message-tool policy.
One more 403 case. A named operator role with sandbox: "required" must target an existing session with recorded required-sandbox provenance, and an omitted or "main" target that resolves to a missing session gets a 403. Create the session through the normal flow first. The WebSocket tools.invoke method follows the same rule.
MCP tools work, cold
Tools from servers in mcp.servers (and enabled bundle plugins) are callable by their agent-facing name, <server>__<tool>, for example granola__list_meetings. Each call connects only the owning server on a private runtime that closes when the call finishes. Nothing is reused or kept warm, so every call pays that server's connect-and-list cost. For a slow server polled every minute, that adds up. If the server can't connect or list its tools you get a 500 with its diagnostic, not a 404.
Servers set to oauth.identity: "per-requester" are unavailable here, because an HTTP call carries no verified sender. To block a whole server, use tools.deny: ["<server>__*"]. The MCP server guide covers the server side.
Status codes
| Code | Meaning | What I check first |
|---|---|---|
| 200 | { ok: true, result } | Nothing |
| 400 | Invalid request or tool input error | agentId vs sessionKey conflict, then args |
| 401 | Unauthorized | Which auth mode the Gateway is in |
| 403 | Blocked by policy, may include requiresApproval | Required-sandbox roles, scopes |
| 404 | Tool not found or not allowlisted | Profile and allow lists before spelling |
| 405 | Method not allowed | You sent GET |
| 408 | Body read timed out | A proxy buffering the request |
| 413 | Body over the size limit (2 MB default) | Inline file content in args |
| 429 | Auth rate-limited, Retry-After set | A script looping on a stale token |
| 500 | Unexpected execution error, sanitized message | Gateway log, or an MCP server that won't connect |
Where each surface fits (and where it does not)
/tools/invoke fits cron jobs and health checks that call a known read tool with known arguments. It is the wrong choice the moment the input comes from someone you don't control, since there is no model in the loop to refuse anything.
/hooks/agent wins for monitoring alerts and GitHub events (through a signature-checking relay, since GitHub can't send the token header). The safety wrapping exists for exactly that kind of payload, and the hook token is separate from the Gateway token, so a leaked one doesn't give away the whole Gateway.
/v1/chat/completions wins when you have an existing tool built on the OpenAI SDK and don't want to rewrite it. It is a full agent run, so it costs what a chat turn costs.
openclaw attach wins for a person at a keyboard who wants Claude Code working inside one session for an hour. It is a poor fit for anything scheduled.
Final verdict
Use /tools/invoke for deterministic read calls from scripts on the Gateway host or your tailnet, and stop paying a model turn to run sessions_list. Keep the default deny list as it is, never add exec to gateway.tools.allow, ignore dryRun until it does something, and read every 404 as a policy question first. If the endpoint being always on bothers you (it bothers me), the fix is keeping the Gateway token off every machine that doesn't need owner access.
Ready to build?
Get the OpenClaw Starter Kit — config templates, 5 production-ready skills, deployment checklist. Go from zero to running in under an hour.
$14 $6.99
Get the Starter Kit →Also in the OpenClaw store
Get the free OpenClaw quickstart guide
Step-by-step setup. Plain English. No jargon.