Agent37 Gateway

August 27, 2026 · View on GitHub

One API for Agent37 agents.

The Agent37 Gateway exposes a small, Responses-style HTTP API for talking to an Agent37 agent. You send it a turn; it routes that turn to the agent, streams the work back, and keeps the conversation going. The streaming contract and request shape are the same whatever agent is behind it — so client code doesn't change when the agent does.

Today it routes to Hermes (the default), OpenClaw, Claude Code, Codex, and OpenCode: pick per request with the agent field.

Want the hosted API? Use Agent37 Cloud. This repo is the gateway service that powers an Agent37 agent.

How it talks to Hermes

The gateway is a small TypeScript/Express server. It spawns a Python worker (server/workers/hermes_worker.py) and speaks newline-delimited JSON to it over stdin/stdout. The worker imports the Hermes AIAgent directly — there is no Hermes HTTP gateway in the loop — which gives structured streaming events, per-turn model and reasoning control, and direct access to Hermes' SessionDB for transcript history and replay.

HTTP / SSE client
  ↕  HTTP + Server-Sent Events
Agent37 Gateway  (Express, :3737)
  ↕  JSONL over stdin/stdout
Python worker    (hermes_worker.py)
  ↕  direct Python import
Hermes AIAgent

State is split deliberately:

  • In-memory live registry buffers the SSE events of each in-flight (and just-finished) response, so a dropped client can reconnect and replay.
  • In-memory response store holds each turn's receipt (status, usage, error, echoed metadata), bounded by a TTL and a count cap and lost on restart — just enough to replay a dropped stream after its live buffer expires.
  • Transcript history and the session list are never duplicated; they're projected on demand from the session's harness backend (Hermes' SessionDB, OpenClaw's history, …). The gateway keeps no session index.

How it talks to OpenClaw

The OpenClaw adapter speaks OpenClaw's WebSocket gateway RPC — OpenClaw's native API, the one its own UI and CLI use — at OPENCLAW_BASE_URL (defaults to a local OpenClaw, http://localhost:18789, when unset; the adapter derives ws:// from it), authenticated with OPENCLAW_TOKEN.

Turns go through chat.send and stream back over OpenClaw's chat/agent events, so the gateway relays text deltas, thinking, and tool activity, and cancel is real (chat.abort stops the run inside OpenClaw). Each session is keyed openresponses-user:{user} under OpenClaw's default agent, where user is the gateway session id. GET /v1/sessions/{id} projects the transcript (sessions.get), the session list projects OpenClaw's own (sessions.list; label/updatedAt become the shared title/last_active), DELETE /v1/sessions/{id} removes the session (sessions.delete; OpenClaw archives the transcript off its active path), and PATCH /v1/sessions/{id} (rename) writes the session's label (sessions.patch).

GET /v1/models lists OpenClaw's configured LLM catalog (models.list) as provider/model ids. A turn that names a model applies it to the session (sessions.patch) — set when chosen, never cleared, so a model picked through OpenClaw's own surfaces isn't clobbered by turns that don't choose — and reasoning_effort maps onto OpenClaw's per-turn thinking level (noneoff, the rest pass through; OpenClaw's ultra is provider max plus proactive subagent orchestration).

Set up OpenClaw

One step: copy gateway.auth.token from ~/.openclaw/openclaw.json into OPENCLAW_TOKEN in your .env:

OPENCLAW_TOKEN=<gateway.auth.token from openclaw.json>

Then route any turn to it with "agent": "openclaw". If OpenClaw runs somewhere other than http://localhost:18789, set OPENCLAW_BASE_URL too. No OpenClaw config changes are needed — the WebSocket gateway is OpenClaw's always-on native surface.

How it talks to Claude Code

The Claude Code adapter drives the Claude Code CLI through the Claude Agent SDK: one query() (one claude process) per turn, resumed by session id, in bypassPermissions mode so tools run unprompted (the instance is the customer's own box, as with the other harnesses). Text and thinking deltas, tool calls and tool results stream back as the same SSE events, and cancel is real (interrupt() stops the run inside Claude Code).

Transcripts live where Claude Code keeps them (~/.claude/projects/<cwd>/<id>.jsonl, keyed by the gateway's workspace directory) and the gateway keeps no index: GET /v1/sessions?agent=claude-code lists that store (Claude Code's own title, custom or auto-generated, as title; the first prompt as preview), GET /v1/sessions/{id} projects the transcript, PATCH /v1/sessions/{id} (rename) writes Claude Code's custom title (its /rename), and DELETE /v1/sessions/{id} removes the transcript. A gateway session id is the Claude Code session UUID with the dashes removed, so a session started from a terminal in the workspace directory shows up in the list too.

The gateway passes no credential: Claude Code runs on its own login. Until one exists, a turn fails with auth_error (and a hint to log in) and GET /v1/health?agent=claude-code reports healthy: false.

GET /v1/models?agent=claude-code lists Claude Code's model aliases (sonnet, opus, fable, haiku, each the latest of its family); a turn's model is passed through as is, and reasoning_effort maps onto Claude Code's dials (none → thinking disabled, minimal|low → effort low, medium|high|xhigh|max → the same effort, ultra → ultracode: xhigh effort plus standing multi-agent workflow orchestration). usage (cost included) and context come from Claude Code's own per-turn result.

Set up Claude Code

Install Claude Code on the machine the gateway runs on and log in:

npm install -g @anthropic-ai/claude-code
claude auth login

Then route any turn to it with "agent": "claude-code". The adapter runs the claude on PATH; set CLAUDE_CODE_BIN to pin a specific binary. An ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN (what claude setup-token prints) in the gateway's environment works instead of the interactive login.

How it talks to Codex

The Codex adapter drives the Codex CLI through codex app-server (newline-delimited JSON-RPC over stdio): one process spawned on demand that lingers a few seconds so a UI burst (list + read + turn) reuses it, then falls to zero idle RAM. Tools run with approvalPolicy: "never" and sandbox: "danger-full-access" (the instance is the customer's own box, as with the other harnesses). Text and reasoning deltas, tool calls and their results stream back as the same SSE events, and cancel is real (turn/interrupt).

A gateway session id is a Codex thread id: the harness store owns the id, so the gateway resolves it before a turn begins — a turn with no session_id creates a Codex thread and returns its id; a turn with one resumes that thread (an id Codex doesn't know is a 400 validation_error). Threads, history, rename, and delete all go through Codex's own store (~/.codex/sessions/**/rollout-*.jsonl); the gateway keeps no index. GET /v1/sessions?agent=codex lists that store (including threads started from a terminal in the workspace), GET /v1/sessions/{id} projects the transcript, PATCH /v1/sessions/{id} (rename) writes Codex's thread name, and DELETE /v1/sessions/{id} removes the thread.

The gateway passes no credential: Codex runs on its own login. Until an account is configured, a turn fails with auth_error (and a hint to log in) and GET /v1/health?agent=codex reports healthy: false.

GET /v1/models?agent=codex lists Codex's live model catalog (owned_by: "openai"); a turn's model is passed through and reasoning_effort maps onto Codex's efforts (none/minimallow, lowxhigh by name, max, and ultra → Codex's multi-agent mode), clamped to the target model's advertised set. usage reports Codex's per-turn tokens (cost_usd is null — Codex bills its own account, not the gateway) and context its window occupancy.

Set up Codex

Install Codex on the machine the gateway runs on and log in:

npm install -g @openai/codex
codex login            # ChatGPT sign-in, or: codex login --with-api-key

Then route any turn to it with "agent": "codex". The adapter runs the codex on PATH; set CODEX_BIN to pin a specific binary and CODEX_HOME to point at a specific config/credentials directory. A bare OPENAI_API_KEY in the environment does not authenticate Codex's app-server on its own — write it to ~/.codex/auth.json with codex login --with-api-key (the Agent37 image's boot script does this from OPENAI_API_KEY automatically).

How it talks to OpenCode

The OpenCode adapter drives the OpenCode CLI through a resident opencode serve (its local HTTP API plus one Server-Sent-Events stream): the server is spawned on demand, kept warm so back-to-back turns reuse it, and killed after OPENCODE_IDLE_MS idle (10 minutes by default) so a casual user costs zero RAM. Tools run with permissions set to allow (the instance is the customer's own box, as with the other harnesses). Text and reasoning deltas, tool calls and their results stream back as the same SSE events, and cancel is real (POST /session/{id}/abort).

A gateway session id is an OpenCode session id: the harness store owns the id, so the gateway resolves it before a turn begins. A turn with no session_id creates an OpenCode session and returns its id; a turn with one continues that session (an id OpenCode doesn't know is a 400 validation_error). Sessions, history, rename, and delete all go through OpenCode's own store; the gateway keeps no index. GET /v1/sessions?agent=opencode lists that store (including sessions started from a terminal), GET /v1/sessions/{id} projects the transcript, PATCH /v1/sessions/{id} (rename) writes OpenCode's session title, and DELETE /v1/sessions/{id} removes the session.

OpenCode answers on the managed Agent37 model out of the box (configured on the Agent37 image), so GET /v1/health?agent=opencode is healthy: true with no login step. Bring your own provider by setting its key in the instance environment (for example OPENAI_API_KEY or OPENROUTER_API_KEY): the provider then appears in the model list and a turn can target it.

GET /v1/models?agent=opencode lists OpenCode's provider catalog (each model owned_by its provider, id in provider/model form); a turn's model picks one and reasoning_effort maps onto OpenCode's variant (none omits it, lowxhigh by name, max and ultra to max), clamped to the target model's advertised set. usage reports OpenCode's per-turn tokens and cost (cost_usd is 0 on free or unpriced models) and context its window occupancy.

Set up OpenCode

Install OpenCode on the machine the gateway runs on:

curl -fsSL https://opencode.ai/install | bash

Then route any turn to it with "agent": "opencode". The adapter runs the opencode on PATH; set OPENCODE_BIN to pin a specific binary. Providers and the default model come from OpenCode's own config (~/.config/opencode); the Agent37 image writes the managed agent37 provider there.

Quickstart

Prerequisites:

  • Node.js 24+
  • A working Hermes install with a configured model/provider

The server itself is Node, but useful agent calls need Hermes. The worker auto-detects ~/.hermes/hermes-agent or the hermes CLI install; override with HERMES_AGENT_DIR / HERMES_PYTHON when Hermes lives somewhere else.

npm install
npm run selftest:worker
npm run dev              # tsx watch on http://localhost:3737

Expected self-test output includes "ok": true. If it reports import_error, set HERMES_PYTHON to the Python inside the Hermes virtualenv, for example:

HERMES_PYTHON=~/.hermes/hermes-agent/venv/bin/python npm run selftest:worker

Then sanity-check the HTTP server:

curl http://localhost:3737/v1/health
curl http://localhost:3737/v1/responses \
  -H 'content-type: application/json' \
  -d '{"input":"hello"}'

For a production-style local run:

npm run prod             # build + run the compiled server

API

Base path is /v1. There is no auth in the gateway — it's a localhost service behind the host, which handles and forwards authentication.

Send a turn — POST /v1/responses

FieldTypeNotes
inputstring, requiredThe message or task.
agentstringhermes, openclaw, claude-code, codex, or opencode. Defaults to the gateway's configured default (GATEWAY_DEFAULT_AGENT, hermes out of the box). Routing is per request, so include it on every turn of a non-default session.
session_idstringContinue a conversation. Omit to start a new one.
filesstring[]Absolute paths of files to attach (write them first with PUT /v1/files/content). Appended to the message as an [Attached files: …] block; the agent reads them from disk.
streambooleantrue for Server-Sent Events; default false.
model / providerstringThe LLM to run on. List options at GET /v1/models.
reasoning_effortstringnoneultra (max = top plain thinking; ultra = top thinking plus the harness's orchestration mode).
modestringchat (default). goal is reserved (returns validation_error for now).
metadataobjectUp to 16 key/value pairs, echoed back.

Non-streaming returns the finished response object:

{
  "id": "…",
  "session_id": "…",
  "status": "completed",          // in_progress | completed | failed | cancelled
  "agent": "hermes",
  "model": null,
  "provider": null,
  "output_text": "…",
  "usage": { "input_tokens": 1840, "output_tokens": 920, "cost_usd": 0.0137 },
  "context": { "used_tokens": 22600, "window_tokens": 256000 }, // tokens occupying the model's context window; null when the harness can't measure it
  "error": null,
  "metadata": null,
  "created": 1748400000000
}

With stream: true the body is a Server-Sent Events stream of named events:

EventPayload
response.created{ id, session_id }
response.reasoning.delta{ text }
response.output_text.delta{ text }
response.tool_call.started{ tool, label }
response.tool_call.completed{ tool, duration_ms }
response.tool_call.failed{ tool, error }
response.completed{ output_text, usage, context }
response.failed{ error: { code, message } }

Follow up on a response

ActionEndpoint
Reconnect a dropped streamGET /v1/responses/{id}/stream (replays a snapshot, then resumes live)
Cancel a running turnPOST /v1/responses/{id}/cancel

Lost the id (page reload, new device)? GET /v1/sessions/{id} returns the running response as active_response_id.

Sessions

ActionEndpoint
ListGET /v1/sessions{ agent, data: [...] } (select the harness with ?agent=hermes|openclaw|claude-code|codex|opencode). Every row is the same shape regardless of harness: { id, title, last_active, message_count, preview }title is the harness's own editable title (what rename writes), last_active is epoch ms, and fields a harness doesn't track are null.
Retrieve, with historyGET /v1/sessions/{id}{ id, agent, active_response_id, history, context } (?agent= to pick the harness; context is the session's last reported context window, null until a turn reports one)
RenamePATCH /v1/sessions/{id} with { "title": "…" }{ id, agent, renamed }. Writes the title straight into the harness's own store (Hermes titles are length-capped and must be unique — a clash is 409 title_conflict; OpenClaw stores it as the session label; Claude Code stores it as its custom session title; Codex stores it as the thread name; OpenCode stores it as the session title). A harness without an editable title answers 405 rename_unsupported.
DeleteDELETE /v1/sessions/{id}

active_response_id is the id of the in_progress response on the session, or null when it is idle. Harnesses persist a turn's messages at turn end, so while active_response_id is set the running turn is normally not in history yet — follow it live with GET /v1/responses/{id}/stream. This is how a client that lost its state (page reload, new device) rediscovers and reattaches to a running turn.

Files

Files live on the instance's disk — the sk_live_ key is the instance root, so a path can name anything on it (there's no jail). A path (resolved, absolute) is the file's identity; there are no file ids. The listing defaults to the agent's workspace (<home>/workspace, the worker's working directory), so files written there are the files the agent reads from disk.

Every entry — in a listing and returned by every write — is a FileEntry:

{
  "name": "leads.csv",            // basename
  "path": "/home/user/leads.csv", // resolved absolute path (the identity)
  "type": "file",                 // file | directory | symlink | other
  "size": 1024,                   // bytes; null for directories
  "modified": 1719500000123,      // mtime, epoch milliseconds
  "hidden": false                 // name starts with "."
}
ActionEndpoint
List one directory levelGET /v1/files?path=<dir> (defaults to the workspace)
Read / preview / downloadGET /v1/files/content?path=<file>&disposition=inline|attachment
Download a folder as .tar.gzGET /v1/files/archive?path=<dir> (defaults to the workspace)
Write raw bytes (create/overwrite/edit/upload)PUT /v1/files/content?path=<file>&overwrite=true|false
Delete (recursive, force)DELETE /v1/files?path=<path>{ ok: true }
Rename / movePATCH /v1/files body { from, to }
Create a directory (mkdir -p)POST /v1/files/dir?path=<dir>

GET /v1/files returns { path, parentPath, entries, truncated } — one level, directories first then name (case-insensitive), capped at 1000 entries (truncated: true past that). parentPath is null at the filesystem root.

GET /v1/files/content sets Content-Type from the extension and streams at any size; disposition defaults to attachment (download), inline lets a browser render it.

GET /v1/files/archive streams a directory as a gzipped tar (.tar.gz), produced by piping the system tar — any size, flat memory, and it unpacks to one top-level folder named after the directory (application/gzip, Content-Disposition: attachment). Symlinks are stored as links, not followed. There is no folder-upload counterpart — recreate a tree with per-file PUT /v1/files/content calls (each mkdir -ps its parents).

PUT /v1/files/content writes the raw request body (not multipart) to the path, creating parent directories as needed, and returns the new FileEntry. overwrite defaults to true; with overwrite=false an existing file is a 409 file_exists. Pass an X-Expected-Mtime header (epoch ms) for optimistic concurrency — if the file changed since you read it, the write is a 412 modified.

The chat loop: write a file with PUT /v1/files/content, pass its path in files on POST /v1/responses, and when the agent replies that it wrote a file, fetch it from GET /v1/files/content.

curl -X PUT --data-binary @leads.csv \
  'http://localhost:3737/v1/files/content?path=/home/user/leads.csv'
# → { "path": "/home/user/leads.csv", "type": "file", "size": 1024, … }

curl http://localhost:3737/v1/responses -H 'content-type: application/json' -d '{
  "input": "Summarize the attached spreadsheet.",
  "files": ["/home/user/leads.csv"]
}'

Files are kept until you delete them yourself (there is no garbage collection), and stored paths assume the instance's home directory stays stable.

Models & health

ActionEndpoint
Models a harness can runGET /v1/models (configured default; add ?agent=hermes|openclaw|claude-code|codex to target one)
Liveness + harness reachabilityGET /v1/health (configured default; add ?agent=hermes|openclaw|claude-code|codex to target one)
VersionGET /v1/version

GET /v1/health reports on the gateway's configured default harness, or the one named by an optional ?agent= query param. It returns { ok, agent, healthy }, plus a legacy hermes field when Hermes is probed. For Claude Code and Codex, healthy also means an account is configured.

GET /v1/models returns the OpenAI list shape, so any OpenAI-compatible client works. It lists the models of one harness — the configured default, or the one named by an optional ?agent= query param (e.g. ?agent=openclaw) — and the response echoes which agent answered. Each entry carries the upstream provider in owned_by plus label, source, and is_default, so a UI can group models by provider and preselect the default:

{
  "object": "list",
  "agent": "hermes",             // which harness this list is for
  "default_model": "hermes-4-405b",
  "default_provider": "nous",
  "data": [
    {
      "id": "hermes-4-405b",
      "object": "model",
      "created": 0,                  // we don't track per-model creation time
      "owned_by": "nous",            // upstream provider
      "label": "Hermes 4 405B",
      "source": "catalog",           // current | catalog | custom | alias
      "is_default": true
    }
  ]
}

Errors

Every error returns a stable, machine-readable body. Branch on code, show message:

{ "error": { "code": "validation_error", "message": "input is required…", "param": "input" } }
CodeHTTPWhen
validation_error400A request field was invalid (see param).
not_a_directory400GET /v1/files or GET /v1/files/archive was given a path that isn't a directory.
response_not_found404No response with that id.
file_not_found404No file at that path.
not_found404Unknown route.
session_busy409A response is already running on the session. error.response_id names it — reattach via GET /v1/responses/{id}/stream or cancel it.
title_conflict409The requested session title is already in use by another session.
file_exists409PUT /v1/files/content?overwrite=false and the file already exists.
modified412PUT /v1/files/content's X-Expected-Mtime no longer matches the file.
rename_unsupported405The targeted harness can't rename sessions (no native editable title).
payload_too_large413Request body exceeded the size limit.
rate_limited429The upstream agent/provider was rate-limited.
agent_error502The agent backend failed (auth, model, provider, etc.).
agent_unavailable503The targeted harness backend isn't available on this instance — never provisioned here (including an image with no Hermes install), or down.
internal_error500An unexpected gateway error.

Agent/worker failures surface their own code and hint where available (e.g. auth_error, quota_exhausted, model_error). One response runs at a time per session; sending a new turn while one is in flight returns 409 session_busy, with the running response's id in error.response_id. On codex and opencode, which resolve the session before the turn starts, a turn that can't reach the harness (its binary isn't on the instance) is a real 503 agent_unavailable, not a 200 failed body.

Testing

npm test          # integration suite against the real local Hermes worker/LLM

npm test drives the real Express app over HTTP/SSE against a throwaway gateway state dir. Response tests call the local Hermes worker and configured LLM; the suite also covers replay, session_busy, cancel, history, and error bodies. The OpenClaw tests run against a local OpenClaw gateway and are skipped automatically when none is running; the Claude Code, Codex, and OpenCode tests run the Claude Code, Codex, and OpenCode CLIs installed on this machine (Claude Code and Codex on their own logins, OpenCode on a free or managed model) and fail the suite when one isn't installed.

Poke it by hand (Bruno)

A Bruno collection lives in bruno/ — open that folder in Bruno, pick the local environment (baseUrl http://localhost:3737), and run the requests top to bottom. Create Response saves the session_id and response id into the environment, so Continue Session, Cancel, and Delete Session just work. The (openclaw) requests do the same for OpenClaw (start openclaw locally first), the (claude-code) requests for Claude Code, the (codex) requests for Codex, and the (opencode) requests for OpenCode (each installed, and logged in where it needs credentials). Upload File writes a file via PUT /v1/files/content and saves its path for Download File.

Configuration

All optional — see .env.example. Highlights: PORT (3737), HOST (0.0.0.0), GATEWAY_DEFAULT_AGENT (the harness a turn routes to when the request omits agent; hermes by default), AGENT37_GATEWAY_HOME (~/.agent37-gateway), the HERMES_* variables that locate the Hermes install, OPENCLAW_BASE_URL / OPENCLAW_TOKEN for the OpenClaw route, CLAUDE_CODE_BIN for the Claude Code route, CODEX_BIN / CODEX_HOME (and CODEX_IDLE_MS) for the Codex route, and OPENCODE_BIN (and OPENCODE_IDLE_MS) for the OpenCode route.

Roadmap

  • goal mode — autonomous, multi-turn runs (the worker primitives are in place).
  • More adapters, behind the same AgentAdapter seam.

License

MIT.