# Billing Source: https://www.agent37.com/docs/agents-api/billing Prepaid and managed in the dashboard: one workspace wallet funds compute and managed usage. Billing is prepaid. You top up one workspace wallet in the dashboard, and everything draws from it: instance compute and managed usage. ## How the balance works * **\$1 free to start.** Your first visit to the dashboard grants a one-time \$1 credit, enough to run your first instance for days, or a mostly-idle [auto-sleep](/docs/agents-api/instances#auto-sleep) instance for a month or more. Until your first real top-up, the workspace runs one instance of any template on the 2 vCPU / 4 GB shape; a larger shape returns `403 tier_limit`. Your first top-up unlocks the 4/8 and 8/16 shapes, and up to 10 instances; once your top-ups total \$100 the cap rises to 50, and at \$250 to 200. See [Instance limits](#instance-limits). * **Top up in the dashboard.** Add funds at [dashboard/cloud/billing](https://www.agent37.com/dashboard/cloud/billing). Top-ups are \$5 to \$1000 each, paid through Stripe. * **Automatic top-up is on by default.** Your first top-up saves its card and turns on a refill rule (below \$10, buy \$25) so the wallet refills itself; adjust or turn it off any time. See [Automatic top-up](#automatic-top-up). * **Instances draw it down.** Compute is metered per minute: time an instance spends running bills its full rate, time spent stopped or sleeping bills its disk alone. See [Per-minute metering](#per-minute-metering). * **Managed usage draws it down.** Managed LLM, Brave search, and Composio calls are metered at cost against the same wallet, gated by each instance's [budget](/docs/agents-api/budgets). * **Delete to stop billing.** Deleting an instance settles its final minutes and ends billing. Nothing is ever prepaid, so there is nothing to refund. There are no balance or billing endpoints on `/v1`. The wallet, top-ups, and the ledger are managed entirely in the [dashboard](https://www.agent37.com/dashboard/cloud/billing). ## Instance limits How many instances a workspace can run at once depends only on how much it has topped up: | Workspace | Instances | | ------------------------------------ | ----------------------------- | | Free (before your first top-up) | 1, on the 2 vCPU / 4 GB shape | | Topped up at least once (any amount) | 10, all shapes | | Top-ups total \$100 or more | 50, all shapes | | Top-ups total \$250 or more | 200, all shapes | The cap rises on its own: your first top-up lifts the workspace to 10 instances, \$100 in total top-ups makes it 50, and \$250 makes it 200, with nothing to request or configure. Need more than 200? Email [vishnu@agent37.com](mailto:vishnu@agent37.com) or use the chat bubble in the dashboard. These are caps on how many instances you can run at once, not a balance requirement: each instance simply [meters its own compute](#per-minute-metering) from the wallet, with no extra reserve for holding several. ## Compute pricing Compute is priced from the instance's `resources`: | Resource | Rate | | -------- | ------------------------- | | vCPU | \$0.80 per vCPU per month | | RAM | \$0.70 per GB per month | | Disk | \$0.09 per GB per month | Applied to the shapes at their default disk: | Shape | Price at the default disk | | ---------------------------------------------- | ------------------------- | | 2 vCPU / 4 GB RAM / 6-20 GB disk (the default) | \$4.94 | | 4 vCPU / 8 GB RAM / 20-40 GB disk | \$10.60 | | 8 vCPU / 16 GB RAM / 40-80 GB disk | \$21.20 | Every template offers the same three shapes, and 2 vCPU / 4 GB is the smallest. Disk is any whole number of GB within the shape's range and defaults to the range minimum; disk above the minimum adds \$0.09 per GB per month. See [Instances](/docs/agents-api/instances) for how to pick a shape with `resources` on create. ### Dedicated and shared instances Instances are **dedicated** by default: the full shape is reserved around the clock, and the prices above are what they bill, flat, every month. Most agents are idle most of the day. A **shared** instance still bursts to the whole shape whenever it works, but bills that shape's monthly average instead of the reservation, from \$1.99 per month on 2 vCPU / 4 GB and never more than the dedicated price. Burst capacity is best effort, so some contention is possible under sustained load. Isolation and every endpoint on this reference behave identically; only the capacity reservation changes. Start dedicated. Reach for **shared** when cost is what you are optimizing: pass `"type": "shared"` on [create](/docs/agents-api/instances#create-an-instance). Shared is enabled per workspace; until yours is, create returns `403 shared_unavailable` with a link to set it up. An instance with [auto-sleep](/docs/agents-api/instances#auto-sleep) on bills its awake minutes at **4x these compute rates**: the platform keeps its capacity wakeable on demand instead of sharing it. Time spent asleep bills disk alone, so a mostly-idle auto-sleep instance still comes out well below the always-on price. See [Per-minute metering](#per-minute-metering). ## Per-minute metering The monthly price is the rate; the wallet is metered per minute (the monthly price divided by 730 hours, divided by 60). What a minute costs depends on what the instance holds: | Time spent | Meters at | | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `running`, and the transitional states (`starting`, `restarting`, `updating`, `stopping`) | The full compute rate. With [auto-sleep](/docs/agents-api/instances#auto-sleep) on, 4x the compute rate. | | `sleeping` or `stopped` | The disk rate alone: \$0.09 per reserved GB per month. | | Parked in cold storage (long-idle sleepers moved off their host) | \$0.03 per GB per month, on the actual compressed bytes stored. | **Create checks a day, debits nothing.** `POST /v1/instances` requires the wallet to hold one day of the instance's running rate (for an `auto_sleep: true` create, one day at its 4x rate); below that, the create returns `402 insufficient_balance` and nothing is provisioned. Nothing is debited at create: the meter starts when the instance first reaches `running`, so a failed create costs nothing and there is nothing to refund. ```json theme={null} { "error": { "code": "insufficient_balance", "message": "This instance costs $0.0068 per hour, metered per minute; creating it requires at least one day of balance ($0.1624). Add balance to your workspace and try again." } } ``` **Each instance meters on its own.** Every instance draws its own metered debits from the wallet; there is no extra balance reserve for running several at once. How many you can run at once is set by your [instance limit](#instance-limits), not by your balance. **Sleep and stop cut the rate; delete ends it.** An instance bills for as long as it exists, but only for what it holds. Stopping (or [auto-sleep](/docs/agents-api/instances#auto-sleep)) releases CPU and RAM and keeps the disk, so parked time bills disk alone. An auto-sleep instance pays for its wake guarantee in its awake minutes, which bill at 4x the compute rate, but one that spends most of its life asleep still costs mostly its disk, about \$0.54 per month at 6 GB. Deleting an instance settles the final window and billing stops. **The meter settles behind the scenes.** Whole minutes are billed at every lifecycle transition and at least hourly; you never see a per-minute charge on an API call. Each debit lands in the [ledger](#ledger-and-spend-visibility) as a metered compute entry. ## Automatic top-up Automatic top-up keeps instances from being suspended when the balance runs low: when it falls below your threshold, your card is charged for a fixed amount. The rule is checked every hour, and once more right before any instance would be suspended, so instances stay up as long as the card charges. * It turns on by default at your first top-up (below \$10, buy \$25), charging the most recently saved card. Adjust both amounts or turn it off at [dashboard/cloud/billing](https://www.agent37.com/dashboard/cloud/billing). * The threshold can be \$1 to \$1000. The purchase amount follows the usual \$5 to \$1000 top-up range. * If a charge fails, automatic top-up turns itself off and emails the workspace owner. Add funds manually, which saves a new card, then re-enable it. ## Past due and suspension The meter is post-paid within the hour, so a busy instance can carry the balance below zero before the hourly settle catches it. When a settle leaves the wallet negative, and [automatic top-up](#automatic-top-up) is off or could not charge, the workspace's instances are suspended: running ones are force-slept, every one is flagged `past_due: true`, and the workspace owner is emailed. A request to a suspended instance's URL returns `402` with `{"error": "instance_suspended"}` instead of waking it, and suspended time is never billed. `true` while the instance is suspended for non-payment. Cleared by topping up. ```json theme={null} { "id": "ab12cd34ef", "status": "sleeping", "past_due": true } ``` Topping up reactivates immediately: the flag clears, the meter re-anchors at the top-up (the suspended gap is never back-billed), and the next request to each instance's URL wakes it. A suspension discards no data, but the force-sleep takes no checkpoint, so the first wake after one boots fresh, like `start` after `stop`. Branch on the error `code`, not the message. See [Errors](/docs/agents-api/errors) for the full list and the response shape. ## Managed usage Managed LLM, Brave search, and Composio calls debit the same wallet at cost, but only within each instance's budget: a monthly cap that resets each UTC month plus optional one-time top-up headroom. The default cap is \$0, so an instance spends nothing on managed services until you raise its cap or top it up. When a managed call is refused, the 402 reason tells you which pot ran dry: `insufficient_balance` means the wallet is empty, `instance_budget_exhausted` means the wallet has funds but the instance hit its cap. See [Budgets](/docs/agents-api/budgets) for the endpoints, rates, and both 402 reasons. ## Ledger and spend visibility Every wallet movement is recorded: top-ups, the signup credit, and metered compute debits land in an append-only ledger, while managed usage is metered per call into its own usage ledger and shown as a per-instance spend breakdown. Review the ledger, your balance, and the spend breakdown at [dashboard/cloud/billing](https://www.agent37.com/dashboard/cloud/billing). For managed spend on a single instance, `GET /v1/instances/{id}/usage` returns a monthly rollup; compute charges are dashboard-only and never appear there. # Managed services & budgets Source: https://www.agent37.com/docs/agents-api/budgets Every instance ships with managed LLM, Brave search, and Composio credentials: metered at cost, gated by a per-instance budget you control. Every instance is created with managed credentials for three services: LLM calls, web search (Brave), and app integrations (Composio). The agent uses them out of the box. Managed calls route and meter through Agent37, so there are no provider or integration keys for you to manage. Each managed call is metered at cost against your workspace wallet, and a per-instance budget caps how much each instance can spend. ## Rates | Service | Rate | | ------------------ | -------------------------------------------- | | Managed LLM | Provider cost, passed through with no markup | | Web search (Brave) | \$0.005 per call (5,000 micros) | | Composio | \$0.000114 per call (114 micros) | All money fields are integer micros: USD x 1,000,000, so \$1.00 is `1000000`. Managed spend debits the workspace wallet, the same wallet that pays for compute. Top it up at [agent37.com/dashboard/cloud/billing](https://www.agent37.com/dashboard/cloud/billing). ## The managed model Those credentials reach the agent as environment variables on the instance, which is what makes them work in a custom image too; [Managed services in your image](/docs/agents-api/managed-services) is the reference for the endpoints and how to wire an agent to them. Managed LLM calls default to the Agent37 default model, a fast low-cost model we pick and keep current. You are not locked to it: the managed provider is OpenAI-compatible and lists every available paid model at its `/v1/models` endpoint, so the agent can switch from inside the session. In Hermes, run `/model` and pick from the Agent37 provider's list. In OpenClaw, run `openclaw models list` and set any `agent37/...` entry as the model. Whatever the model, billing stays the same: provider cost passed through with no markup, metered against the budget and wallet. ## How budgets work A budget has two parts: * **Monthly cap.** A spending ceiling that resets at the start of each UTC month. It defaults to \$0, so managed calls are refused until you grant headroom. * **One-time credit.** A ceiling that persists across months and is consumed only after the monthly portion is exhausted. Budget figures are ceilings, not money. The workspace wallet is the only pot of dollars; raising a cap or topping up an instance moves no funds. The sum of caps across your instances can exceed the wallet balance, which is fine: caps bound each instance, the wallet bounds the total. ## Set a budget at create Pass `budget` in the create body to grant headroom from the first call. Monthly managed-spend cap in micros. Resets each UTC month. One-time headroom in micros. Persists until consumed. ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/instances \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "research-bot", "budget": { "monthly_cap_micros": 5000000, "credit_micros": 1000000 } }' ``` ```python python theme={null} import requests H = {"Authorization": "Bearer sk_live_...", "Content-Type": "application/json"} instance = requests.post( "https://api.agent37.com/v1/instances", headers=H, json={ "name": "research-bot", "budget": {"monthly_cap_micros": 5000000, "credit_micros": 1000000}, }, ).json() ``` ```javascript node theme={null} const H = { "Authorization": "Bearer sk_live_...", "Content-Type": "application/json", }; const instance = await (await fetch("https://api.agent37.com/v1/instances", { method: "POST", headers: H, body: JSON.stringify({ name: "research-bot", budget: { monthly_cap_micros: 5000000, credit_micros: 1000000 }, }), })).json(); ``` ## Endpoints | Method | Path | Returns | | ------- | ---------------------------------- | ------------------------------------------------ | | `GET` | `/v1/instances/{id}/budget` | `200` the budget object | | `PATCH` | `/v1/instances/{id}/budget` | `200` the updated budget object | | `POST` | `/v1/instances/{id}/budget/top-up` | `200` the updated budget object | | `GET` | `/v1/instances/{id}/usage` | `200` `{ period, total_micros, by_integration }` | All three budget endpoints return the same budget object. ## The budget object The monthly spending ceiling. Managed spend counted against the cap this month. `monthly_cap_micros` minus `monthly_consumed_micros`, floored at 0. The UTC month the counters cover, formatted `YYYY-MM`. One-time headroom left. Consumed only after the monthly portion is exhausted. Epoch seconds of the last budget write. The budget is first written when the instance is created; cap changes, top-ups, and managed spend all update it. ```bash curl theme={null} curl https://api.agent37.com/v1/instances/ab12cd34ef/budget \ -H "Authorization: Bearer sk_live_..." ``` ```json response theme={null} { "monthly_cap_micros": 5000000, "monthly_consumed_micros": 412380, "monthly_remaining_micros": 4587620, "monthly_period": "2026-06", "credit_remaining_micros": 1000000, "updated_at": 1781136000 } ``` ## Set the monthly cap `PATCH /v1/instances/{id}/budget` sets the cap for the current and future months. It takes effect immediately. The new monthly cap in micros. Must be a non-negative integer. ```bash curl theme={null} curl -X PATCH https://api.agent37.com/v1/instances/ab12cd34ef/budget \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "monthly_cap_micros": 20000000 }' ``` ```python python theme={null} budget = requests.patch( "https://api.agent37.com/v1/instances/ab12cd34ef/budget", headers=H, json={"monthly_cap_micros": 20000000}, ).json() ``` ```javascript node theme={null} const budget = await (await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/budget", { method: "PATCH", headers: H, body: JSON.stringify({ monthly_cap_micros: 20000000 }), }, )).json(); ``` Setting the cap to `0` turns managed services off for the instance once any remaining top-up headroom is consumed. ## Add one-time headroom `POST /v1/instances/{id}/budget/top-up` adds to `credit_remaining_micros`. Use it for a burst of work you don't want to bake into the monthly cap. Headroom to add, in micros. Must be a positive integer. Up to 64 characters matching `^[A-Za-z0-9_-]{1,64}$`. Repeating a request with the same key returns the current budget without adding again, so retries are safe. ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/budget/top-up \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount_micros": 10000000, "idempotency_key": "june-burst-1" }' ``` ```python python theme={null} budget = requests.post( "https://api.agent37.com/v1/instances/ab12cd34ef/budget/top-up", headers=H, json={"amount_micros": 10000000, "idempotency_key": "june-burst-1"}, ).json() ``` ```javascript node theme={null} const budget = await (await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/budget/top-up", { method: "POST", headers: H, body: JSON.stringify({ amount_micros: 10000000, idempotency_key: "june-burst-1", }), }, )).json(); ``` ## Read managed spend `GET /v1/instances/{id}/usage?month=YYYY-MM` returns the instance's managed-spend rollup for one UTC month. Omit `month` for the current month; an invalid value returns 400. The UTC month covered, formatted `YYYY-MM`. Total managed spend for the month. Per-service breakdown. `llm` carries `cost_micros`, `calls`, `input_tokens`, and `output_tokens`; `brave` and `composio` carry `cost_micros` and `calls`. ```bash curl theme={null} curl "https://api.agent37.com/v1/instances/ab12cd34ef/usage?month=2026-06" \ -H "Authorization: Bearer sk_live_..." ``` ```json response theme={null} { "period": "2026-06", "total_micros": 412380, "by_integration": { "llm": { "cost_micros": 391582, "calls": 42, "input_tokens": 184032, "output_tokens": 96110 }, "brave": { "cost_micros": 20000, "calls": 4 }, "composio": { "cost_micros": 798, "calls": 7 } } } ``` Usage covers managed spend only. Compute charges never appear here; the full billing ledger lives in the dashboard, not on `/v1`. See [Billing](/docs/agents-api/billing). ## When a managed call is refused A managed call that can't be covered fails with a 402 carrying one of two reasons. The instance keeps running either way; only managed calls are refused, and the refusal surfaces inside the instance on the call the agent was making, so the agent typically reports it in its reply. | Reason | What happened | Fix | | --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `insufficient_balance` | The workspace wallet is empty. | Top up the wallet at [agent37.com/dashboard/cloud/billing](https://www.agent37.com/dashboard/cloud/billing). | | `instance_budget_exhausted` | The wallet has funds, but this instance hit its budget. | Raise the monthly cap with `PATCH .../budget`, or add headroom with `POST .../budget/top-up`. | For the hosting API error catalog, see [Errors](/docs/agents-api/errors). ## Connecting apps The agent connects apps two ways. In conversation, ask it to connect Gmail, Slack, Notion, or any other Composio-supported app, and it replies with an authorization link your user opens to grant access. Or drive the flow over the Hosting API; see [App integrations](/docs/agents-api/integrations). ## Bring your own keys To run a service on your own account, put your own provider key inside the instance with [exec](/docs/agents-api/exec) or the agent's in-instance config. Calls made with your own keys go straight to the provider and never touch the managed meter or the budget. # Use your own model Source: https://www.agent37.com/docs/agents-api/byo-model Run Hermes on Agent37 with your own OpenRouter key: a forkable kit with an LLM proxy you host, a revocable token per instance, per-instance spend caps, and real per-instance cost tracking. Every instance boots with a [managed model](/docs/agents-api/managed-services): zero setup, and Agent37 meters the spend from your wallet against each instance's [budget](/docs/agents-api/budgets). When you'd rather own the model layer (your `OPENROUTER_API_KEY`, your choice of models, your own per-user metering and limits), the **hermes-byo-model** kit is the supported path. It's a complete forkable app in which every Hermes turn runs on your OpenRouter account, through a proxy hosted in your own app. The full app, ready to fork: auth, workspaces, chat, files, a per-agent model budget with real spend, and the money proxy that enforces it. Next.js and Supabase, deploys to Vercel. MIT. ## Managed model or your own? | | Managed (default) | Your own (this kit) | | ------------ | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | Setup | None, built into every instance | Fork the kit, add your `OPENROUTER_API_KEY` | | Model choice | Agent37's managed catalog | Anything your OpenRouter key can reach, 300+ models | | Turn path | Instance → Agent37's starter credentials | Instance → an LLM proxy in your app → OpenRouter | | Spend | Billed from your wallet, capped by the instance [budget](/docs/agents-api/budgets) | Billed by OpenRouter to you; free on Agent37's side apart from compute | | Limits | The instance budget returns `402` mid-chat | Your proxy decides; the kit caps each agent per month | | Cost data | [Usage endpoint](/docs/agents-api/billing), per instance | Your own table: per agent, per model, per day | Compute is billed the same either way: an instance is a computer, whoever's model it calls. ## How it works ```text theme={null} agent instance (Hermes on Agent37 Cloud) └─ ~/.hermes/config.yaml → custom provider: https://your-app.com/api/llm/v1 api_key: orp_… (a per-agent token, worthless anywhere else) └─ your app: token → agent → month-to-date spend vs that agent's cap 402 when the agent is over budget; the agent says so in chat forward to OpenRouter with your key, usage accounting on read the real cost off the reply (SSE included) → Postgres └─ OpenRouter → Claude / GPT / Kimi / Gemini / … ``` * **Your key stays home.** The instance holds only a revocable per-agent token; the OpenRouter key lives in your app's environment and never reaches an instance or the browser. Rotating a token is a button in the kit's Settings tab. * **Per-agent budgets, enforced before the call.** Each agent carries a monthly cap. The proxy checks month-to-date spend first and refuses with `402` when it's reached, and the agent surfaces that as a plain message in chat rather than failing silently. * **Real cost, not estimates.** The proxy asks OpenRouter to include usage accounting and records the dollars it reports, per agent, per model, per day. That table is what you'd invoice your own users from. * **The clean base image.** The kit registers a workspace [template](/docs/agents-api/templates) on Agent37's `hermes-base` image, which boots with no model provider at all, so the config the app writes is the only one the agent has. The stock `agent37-hermes` template re-wires the managed model at every boot, which would leave a second, Agent37-billed model sitting in the picker. Nothing here is OpenRouter-specific on the Agent37 side. Hermes needs an OpenAI-compatible endpoint serving `GET /v1/models` and `POST /v1/chat/completions`, so you can point the kit's proxy at any provider, or at your own inference, by changing one constant. OpenRouter is the default because one key reaches every major model. ## Get started Three secrets, each behind a login: an `sk_live_` [API key](https://www.agent37.com/dashboard/cloud/api-keys) from a funded workspace (see [Billing](/docs/agents-api/billing)), an `OPENROUTER_API_KEY` from [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) with credit on it, and a [Supabase access token](https://supabase.com/dashboard/account/tokens); the kit provisions a free Supabase project for you. ```bash theme={null} git clone https://github.com/agent37-platform/hermes-byo-model cd hermes-byo-model ``` The fastest path is agent-driven: open the folder in Claude Code or Codex and paste the setup prompt from the README. It asks for your three keys, provisions Supabase, and registers the agent template in your workspace. By hand: `npm install`, then `npm run setup` twice. The first run creates `.env.local` and exits asking for the keys; paste them in and run it again, then run `npm run templates:publish`. ```bash theme={null} npm run dev ``` Open `http://localhost:3000`, sign up, and create an agent. Agents run on Agent37 Cloud and call your app for *every* turn, so the app needs a public URL: `npm run dev` opens a free cloudflared quick tunnel automatically (it needs the `cloudflared` binary, `brew install cloudflared`, but no Cloudflare account), and in production it's just your deployed URL. Creating an agent without one is refused, rather than producing an agent with no way to think. The first turn takes a minute: a new agent is configured over [exec](/docs/agents-api/exec) and restarted before it answers. After that, the agent's **Settings** tab shows what each conversation cost you within seconds of the reply. ## What you get The kit is the [Agent37 white-label dashboard](/docs/agents-api/white-label) with the model layer swapped: the same multi-tenant app (email sign-in, workspaces, instance management, native **Chat** and **Files** tabs) plus a per-agent model budget, live spend on your OpenRouter account, and a token you can rotate. The chat model picker lists every model your key can reach; a one-line allow-list in `src/config/models.ts` narrows that to the models you approve, enforced in the proxy rather than trusted in the UI. Fork it, rebrand it (`src/config/branding.ts`), and ship it. Happy with the managed model and just want the app? Use the [white-label dashboard](/docs/agents-api/white-label): same experience on Agent37's built-in credentials, one key fewer. Want your own **integrations** instead of your own model? That's [Use your own Composio](/docs/agents-api/composio). # Send a message Source: https://www.agent37.com/docs/agents-api/chat The core call: POST /v1/responses on your instance URL runs a turn through your agent, streams or returns the result, and continues the thread. `POST /v1/responses` is the core call. You make it against your instance, not against `api.agent37.com`: every instance serves its own chat API at `https://{instanceId}.agent37.app`, the `url` of the default port in the create response. This page uses `https://ab12cd34ef.agent37.app`. Authenticate with the same `sk_live_` key you use on the hosting API, sent as the `X-Agent37-Key` header. The call is agentic by default: the agent can browse, run code, use a terminal, read and write files, call connected tools, and reason across many steps before answering. ## Request body Request bodies are capped at 2 MB; anything larger returns `413 payload_too_large`. The message or task, a plain string. There is no image field; to attach files, upload them first and list their paths in `files`. See [Sessions](/docs/agents-api/sessions) for how history carries across turns. Paths of files on the instance to attach to this turn, typically the `path` returned by [`PUT /v1/files/content`](/docs/agents-api/files). Each must name an existing file on the instance, or the call returns `400 validation_error`. The paths are appended to the input, and the agent reads them from disk. Continue an existing conversation. Omit it to start a new one; the response returns the new session's id. The harness owns sessions and creates one on first use, so an id it has not seen simply starts a fresh thread under that id rather than erroring. `true` returns a Server-Sent Events stream; `false` returns the finished response as one JSON body. See [Streaming](/docs/agents-api/streaming). The LLM to run this turn on. Omit it to use the session's current model (the instance default on a new session). List what the instance can run with `GET /v1/models`; see [Models](/docs/agents-api/models). The model's provider, for example `anthropic`. Both `model` and `provider` are set per turn, and sending them on a continuation updates the session's stored pair for the turns that follow. How hard the model thinks: `none`, `minimal`, `low`, `medium`, `high`, or `xhigh`. Up to 16 key/value pairs, at most 64 KB serialized. Echoed back on the response object, never interpreted. Which agent harness runs the turn, `hermes` or `openclaw`. Omit it to use the instance's configured default (`hermes` on `agent37-hermes`). Routing is per request: the gateway keeps no session-to-agent binding, so if a session runs on a non-default harness, send `agent` on every turn of it. Targeting a harness the instance was not provisioned with returns `503 agent_unavailable`. `chat` runs one turn and replies. `goal` is reserved: sending it returns `400 validation_error` today. `instance_id` in the body is accepted and ignored. The URL names the instance: one gateway per instance, so there is nothing to route. ## Response The response object. Ids are 32-character hex strings; timestamps from the gateway are epoch milliseconds. The response id. Use it to reconnect or cancel the turn. The conversation this turn belongs to. Reuse it on the next call to continue the thread. `in_progress`, then a terminal `completed`, `failed`, or `cancelled`. The agent that ran the turn, `hermes` or `openclaw`. The model the turn ran on, `null` when none was set. The model's provider, `null` when none was set. The agent's final answer. Always a string, empty if the turn produced none. Token counts and cost for the turn: `{ input_tokens, output_tokens, cost_usd }`. `cost_usd` is absent or `null` when the provider did not report a cost. Set when the turn failed: `{ code, message, param?, hint? }`. See [Errors](/docs/agents-api/errors). Your request metadata, echoed back verbatim. When the turn started, epoch milliseconds. A failed turn does not reject the HTTP call. The POST still returns 200 with `status: "failed"` and `error` set. Branch on `status`, not on the HTTP code. On a non-streaming call the gateway sends the `200` and headers as soon as the turn starts, then keeps the connection alive with a whitespace tick every 25 seconds while the agent works. The JSON body arrives when the turn finishes, prefixed by that whitespace. Leading whitespace is valid JSON, so standard parsers handle it unchanged. Don't treat the early headers as the response being ready. ## Example ```bash curl theme={null} curl https://ab12cd34ef.agent37.app/v1/responses \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "input": "Research the top 3 EV makers, write a memo." }' ``` ```python python theme={null} import requests r = requests.post( "https://ab12cd34ef.agent37.app/v1/responses", headers={ "X-Agent37-Key": "sk_live_...", "Content-Type": "application/json", }, json={"input": "Research the top 3 EV makers, write a memo."}, ).json() ``` ```javascript node theme={null} const r = await (await fetch("https://ab12cd34ef.agent37.app/v1/responses", { method: "POST", headers: { "X-Agent37-Key": "sk_live_...", "Content-Type": "application/json", }, body: JSON.stringify({ input: "Research the top 3 EV makers, write a memo.", }), })).json(); ``` ```json response theme={null} { "id": "c91d2a7e84f04b6f9a3d5e1c0b87f4a2", "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "status": "completed", "agent": "hermes", "model": null, "provider": null, "output_text": "Memo: the top 3 EV makers...", "usage": { "input_tokens": 1840, "output_tokens": 920, "cost_usd": 0.0137 }, "error": null, "metadata": null, "created": 1781136000000 } ``` Set `stream: true` to receive Server-Sent Events as the agent reasons, calls tools, and writes its answer. The terminal event carries the final `output_text` and `usage`. See [Streaming](/docs/agents-api/streaming). ## Continue a conversation The first message omits `session_id` and starts a session. The reply returns a `session_id`; pass it on the next message to continue the same thread. The session holds the full history, so you never resend a transcript: you send only the new input. ```bash curl theme={null} # 1. start a session: omit session_id curl https://ab12cd34ef.agent37.app/v1/responses \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "input": "Research the top 3 EV makers, write a memo." }' # -> { "id": "c91d2a7e84f04b6f9a3d5e1c0b87f4a2", # "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", # "status": "completed", ... } # 2. continue it: reuse the session_id, send only the new input curl https://ab12cd34ef.agent37.app/v1/responses \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "input": "Make it shorter, add a quote." }' ``` ```python python theme={null} import requests BASE = "https://ab12cd34ef.agent37.app" H = {"X-Agent37-Key": "sk_live_...", "Content-Type": "application/json"} # 1. start a session: omit session_id first = requests.post( f"{BASE}/v1/responses", headers=H, json={"input": "Research the top 3 EV makers, write a memo."}, ).json() # 2. continue it: reuse the session_id, send only the new input requests.post( f"{BASE}/v1/responses", headers=H, json={ "session_id": first["session_id"], "input": "Make it shorter, add a quote.", }, ) ``` ```javascript node theme={null} const BASE = "https://ab12cd34ef.agent37.app"; const H = { "X-Agent37-Key": "sk_live_...", "Content-Type": "application/json", }; // 1. start a session: omit session_id const first = await (await fetch(`${BASE}/v1/responses`, { method: "POST", headers: H, body: JSON.stringify({ input: "Research the top 3 EV makers, write a memo.", }), })).json(); // 2. continue it: reuse the session_id, send only the new input await fetch(`${BASE}/v1/responses`, { method: "POST", headers: H, body: JSON.stringify({ session_id: first.session_id, input: "Make it shorter, add a quote.", }), }); ``` **One active turn per session.** A session runs one response at a time. Sending new input while one is in flight returns `409 session_busy`, normally with the running response's id in `error.response_id`. Use another session, reattach to the running turn, or cancel it first. To list a user's threads, read a thread's history, or delete one, see [Sessions](/docs/agents-api/sessions). To pick a model per turn, see [Models](/docs/agents-api/models). ## Follow up on a response Every response has an id you can use after the call returns. | Method | Path | Returns | | ------ | --------------------------- | ----------------------------------------------------------- | | `GET` | `/v1/responses/{id}/stream` | `200` an SSE stream: replays all events, then attaches live | | `POST` | `/v1/responses/{id}/cancel` | `200` the current response object | `GET /v1/responses/{id}/stream` replays every event so far in order, then stays attached live, so a dropped connection never loses the answer, including after the turn has finished, while the response is still retained. See [Streaming](/docs/agents-api/streaming) for the replay window. Lost the id (page reload, new device)? [`GET /v1/sessions/{id}`](/docs/agents-api/sessions#retrieve-a-session-with-history) returns the running response as `active_response_id`. `POST /v1/responses/{id}/cancel` takes no body and stops a running turn, best effort. It returns 200 with the current response object. Cancelling a finished response is a no-op that returns its terminal state, still 200. Cancel does not rewind. Whatever the agent has already done (files written, emails sent, tools called) is not undone. The response ends with `status: "cancelled"`. ## Status values A response moves from `in_progress` to exactly one terminal status. | Status | Meaning | | ------------- | ----------------------------------------------------- | | `in_progress` | The turn is running. | | `completed` | The turn finished and `output_text` holds the answer. | | `failed` | The turn ended on an error; `error` says why. | | `cancelled` | You stopped the turn with `cancel`. | # Build a chat app Source: https://www.agent37.com/docs/agents-api/chat-app Give every user their own always-on agent. The simplest thing to build on the Agent API, and where most teams start. The pattern is four calls: create one instance per user at signup, start one session per chat thread, list sessions for the sidebar, and fetch one session for the open thread. Everything on this page, runnable: create instances from a table, stream replies token by token, list and reopen threads, cancel a turn. Express plus vanilla JS, no build step. Clone it, add your key, `npm start`. ## One key, two base URLs Two base URLs, one key, two headers: `https://api.agent37.com` manages instances and takes the key as `Authorization: Bearer`; each instance serves its own chat API at `https://{instanceId}.agent37.app` (the id is the hostname) and takes the same key as `X-Agent37-Key`, leaving `Authorization` free for your own app. See [Core concepts](/docs/agents-api/concepts). Each step below shows the call twice: as bare curl, and as `fetch` where it lands in your app's server code. The curl tabs use `ab12cd34ef` as the instance id; your app reads the id it stored at signup. ## The shape of it When a user signs up, create one [instance](/docs/agents-api/instances) for them, tagged with your own user id. That instance is their agent from then on. ```bash curl theme={null} curl https://api.agent37.com/v1/instances \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "user": "u_882", "name": "chat-u_882", "budget": { "credit_micros": 1000000 } }' ``` ```javascript node theme={null} const inst = await (await fetch("https://api.agent37.com/v1/instances", { method: "POST", headers: { Authorization: `Bearer ${process.env.AGENT37_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ user: "u_882", name: "chat-u_882", budget: { credit_micros: 1000000 }, }), })).json(); await db.users.update("u_882", { instanceId: inst.id }); // inst.id is bare, e.g. "ab12cd34ef", and doubles as the hostname: // https://ab12cd34ef.agent37.app ``` Omitting `template` gives you `agent37-hermes`, the default, on the default 2 vCPU / 4 GB RAM / 6 GB disk shape, billed from your workspace wallet (see [Billing](/docs/agents-api/billing)). Each create uses the template's newest published image; for a fleet where every signup must get the identical image, pass a [version-pinned template](/docs/agents-api/templates#pin-a-template-version) instead (`"template": "agent37-hermes@"`). The `budget.credit_micros` field grants one-time managed-spend headroom so the agent's LLM calls work from the first message; without it the per-instance [budget](/docs/agents-api/budgets) defaults to \$0. The call is synchronous and returns `201` with `status: "running"`: the instance's computer is up. The agent inside is still booting, usually seconds but up to a few minutes on a cold host, so before the first message poll `GET /v1/health` on the instance URL until it answers with `"ok": true` (see [Health & version](/docs/agents-api/health)). Store `inst.id` on the user row. Each thread is a session on the user's instance. Send the first turn to the instance URL with no `session_id`; the reply mints one. Store it on your thread row, then send `session_id` plus the new `input` on every later turn. The session keeps the full history, so you never resend a transcript. ```bash curl theme={null} # new thread: first turn, no session_id; the reply carries the new one curl https://ab12cd34ef.agent37.app/v1/responses \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "input": "Research the top 3 EV makers, write a memo." }' # later turns: session_id and the new input only curl https://ab12cd34ef.agent37.app/v1/responses \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "input": "Make it shorter, add a quote." }' ``` ```javascript node theme={null} // new thread: first turn, no session_id const first = await (await fetch( `https://${user.instanceId}.agent37.app/v1/responses`, { method: "POST", headers: { "X-Agent37-Key": process.env.AGENT37_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ input: "Research the top 3 EV makers, write a memo.", }), } )).json(); await db.threads.create({ userId: "u_882", sessionId: first.session_id }); // first.session_id, e.g. "7f3e0b6c52a949d2b1c4a8e9d0f31726" // later turns: session_id and the new input only const reply = await (await fetch( `https://${user.instanceId}.agent37.app/v1/responses`, { method: "POST", headers: { "X-Agent37-Key": process.env.AGENT37_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ session_id: thread.sessionId, input: "Make it shorter, add a quote.", }), } )).json(); render(reply.output_text); ``` Stream every reply so the UI fills in as the agent reasons, calls tools, and writes. Set `stream: true` and read the Server-Sent Events; see [Streaming](/docs/agents-api/streaming) for the full event list and a client parser. `GET /v1/sessions` on the instance URL lists the harness's sessions, newest first, without history. ```bash curl theme={null} curl https://ab12cd34ef.agent37.app/v1/sessions \ -H "X-Agent37-Key: sk_live_..." ``` ```javascript node theme={null} const { agent, data } = await (await fetch( `https://${user.instanceId}.agent37.app/v1/sessions`, { headers: { "X-Agent37-Key": process.env.AGENT37_API_KEY } } )).json(); // Hermes: [{ id, title, model, message_count, started_at, last_active, preview }] // timestamps are epoch milliseconds ``` On Hermes the list already carries a `title`, and you can set it with `PATCH /v1/sessions/{id}` (`{ "title": "..." }`); the first user message usually makes a good default. Harnesses that do not store a title answer `405`; for those, keep titles in your own database keyed by `session_id`. `GET /v1/sessions/{id}` returns the session with its full transcript in `history`, in order. ```bash curl theme={null} curl https://ab12cd34ef.agent37.app/v1/sessions/7f3e0b6c52a949d2b1c4a8e9d0f31726 \ -H "X-Agent37-Key: sk_live_..." ``` ```javascript node theme={null} const session = await (await fetch( `https://${user.instanceId}.agent37.app/v1/sessions/${thread.sessionId}`, { headers: { "X-Agent37-Key": process.env.AGENT37_API_KEY } } )).json(); // session.history: [{ id, session_id, role, content, thinking?, created_at }] // session.active_response_id: the running response's id, or null when idle ``` Render each message by `role` (`user`, `assistant`, or `system`). If `active_response_id` is set, a turn is still running and its messages are not in `history` yet, so reattach with `GET /v1/responses/{id}/stream` to render it live (this is how a page reload mid-turn recovers the stream). When a user deletes a thread, `DELETE /v1/sessions/{id}` removes it; see [Sessions](/docs/agents-api/sessions). ## Handle a busy session A session runs one response at a time. Posting a new turn while one is in flight returns `409`: ```json theme={null} { "error": { "code": "session_busy", "message": "A response is already running on this session.", "hint": "Reattach with GET /v1/responses/{response_id}/stream, cancel it, or start another session.", "response_id": "c91d2a7e84f04b6f9a3d5e1c0b87f4a2" } } ``` `error.response_id` is the running response, so even a client that lost its state can reattach to it or cancel it. Three good ways to handle it in a chat UI: * **Disable the composer** while a turn runs, and re-enable it when the reply arrives (the non-streaming call returning, or the terminal streaming event). * **Offer a stop button** that calls `POST /v1/responses/{id}/cancel` on the instance URL. With `stream: true` the first event, `response.created`, hands you the response id immediately, which is what makes the button possible. Cancel is best effort: the response ends with `status: "cancelled"`, and whatever the agent already did is not undone. * **Reattach instead of erroring**: on a 409, `GET /v1/responses/{response_id}/stream` replays the running turn from its start and follows it live. If `error.response_id` is absent (a rare race, or an older gateway), read `active_response_id` from `GET /v1/sessions/{id}` instead. The [hermes-chat example](https://github.com/agent37-platform/examples/tree/main/hermes-chat) wires up the first two: the composer locks while a turn is in flight, and the stop button cancels it. Other threads are unaffected: each session has its own lock, so one user can run turns in several threads at once. # Use your own Composio Source: https://www.agent37.com/docs/agents-api/composio Run Hermes and OpenClaw on Agent37 with your own Composio API key: a forkable kit with a Composio MCP proxy you host, per-agent account isolation, OAuth connect, and per-agent tool-call metering. Every instance ships with [managed Composio](/docs/agents-api/integrations): zero setup, and Agent37 meters the agent's tool calls from your wallet. When you'd rather own the integration layer (your `COMPOSIO_API_KEY`, your Composio project, your users' connected accounts, your own per-user metering), the **hermes-openclaw-composio** kit is the supported path. It's a complete forkable app in which Hermes and OpenClaw agents reach Gmail, Slack, Notion, GitHub, and hundreds of other apps through a Composio MCP proxy hosted in your own app. The full app, ready to fork: auth, workspaces, chat, files, an integrations catalog with OAuth connect, and per-agent tool-call metering, all on your Composio project. Next.js and Supabase, deploys to Vercel. MIT. ## Managed Composio or your own? | | Managed (default) | Your own (this kit) | | ---------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | Setup | None, built into every instance | Fork the kit, add your `COMPOSIO_API_KEY` | | Composio project | Agent37's | Yours: your dashboard, your auth configs | | Tool-call path | Instance → Agent37's managed credentials | Instance → an MCP proxy in your app → Composio | | Metering | \$0.000114/call from your wallet, capped by the instance [budget](/docs/agents-api/budgets) | Free on Agent37's side; you count calls per agent in your own database | | Connect UI | [Integrations endpoints](/docs/agents-api/integrations) on the Hosting API | The kit's built-in **Integrations** tab, on your key | Either way the model stays managed: agents chat on Agent37's built-in LLM credentials and that spend is billed per instance as usual. Only the integration layer moves. ## How it works ```text theme={null} agent instance (Hermes or OpenClaw, on Agent37 Cloud) └─ MCP client: apps → https://your-app.com/api/composio-mcp (per-agent token) └─ your app: token → agent → Composio tool-router session scoped to one Composio user per agent (isolated accounts) every tools/call metered per agent, per day └─ Composio → Gmail / Slack / Notion / … ``` * **Your key stays home.** The instance only ever holds a revocable per-agent proxy token; the Composio API key lives in your app's environment and never reaches an instance or the browser. * **Per-user isolation.** Each agent maps to its own Composio user, and Composio binds account resolution server-side, so one user's Gmail can never surface in another user's agent. * **Per-user metering.** The proxy counts every billable `tools/call` into Postgres, shown per agent in the kit's **Integrations** tab, the basis for your own limits or billing. * **Clean base images.** The kit publishes two workspace [templates](/docs/agents-api/templates) built on Agent37's `hermes-base` and `openclaw-base` images. The stock templates rewrite their integration wiring on every boot; these never do, so your Composio config survives restarts and updates. The bases themselves boot with no model or integration config; the kit's post-create bootstrap writes both the managed model and your Composio MCP endpoint into each agent. ## Get started Three secrets, each behind a login: an `sk_live_` [API key](https://www.agent37.com/dashboard/cloud/api-keys) from a funded workspace (see [Billing](/docs/agents-api/billing)), your `COMPOSIO_API_KEY` from [platform.composio.dev](https://platform.composio.dev), and a [Supabase access token](https://supabase.com/dashboard/account/tokens); the kit provisions a free Supabase project for you. ```bash theme={null} git clone https://github.com/agent37-platform/hermes-openclaw-composio cd hermes-openclaw-composio ``` The fastest path is agent-driven: open the folder in Claude Code or Codex and paste the setup prompt from the README. It asks for your three keys, provisions Supabase, publishes the kit's two agent templates into your workspace (the Hermes one via an Agent37 [cloud build](/docs/agents-api/custom-image), so no local Docker is needed), and starts the app. By hand: `npm install`, then `npm run setup` twice. The first run creates `.env.local` and exits asking for the keys; paste them in and run it again, then run `npm run templates:publish`. ```bash theme={null} npm run dev ``` Open `http://localhost:3000`, sign up, and create an agent. Agents run on Agent37 Cloud, so tool calls need your app on a public URL. In dev, `npm run dev` starts a free cloudflared quick tunnel automatically (it needs the `cloudflared` binary, `brew install cloudflared`, but no Cloudflare account) and agents wire themselves to it; in production it's just your deployed URL. The repo's `SETUP.md` has the authoritative steps and the Vercel deploy guide. ## What you get The kit is the [Agent37 white-label dashboard](/docs/agents-api/white-label) with the integration layer swapped: the same multi-tenant app (email sign-in, workspaces, instance management, native **Chat** and **Files** tabs) plus an **Integrations** tab that browses your Composio catalog, runs OAuth connects against your project, and shows each agent's tool calls over the last 30 days. Fork it, rebrand it (`src/config/branding.ts`), and ship it. Happy with managed Composio and just want the app? Use the [white-label dashboard](/docs/agents-api/white-label): same experience on Agent37's built-in credentials, one key fewer. # Core concepts Source: https://www.agent37.com/docs/agents-api/concepts The two planes, the resource model, and the money model behind every Agent37 Cloud call. Agent37 Cloud is two APIs that share one key, and three resources that nest. ## Two planes, one key | Plane | Base URL | What it serves | | --------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Hosting API** | `https://api.agent37.com/v1` | Manages the fleet: [instances](/docs/agents-api/instances), [templates](/docs/agents-api/templates), [budgets](/docs/agents-api/budgets), [exec](/docs/agents-api/exec). | | **Agent API** | `https://{instanceId}.agent37.app` | The agent itself. Every instance serves its own API: `POST /v1/responses` for [chat](/docs/agents-api/chat), plus `/v1/sessions`, `/v1/files`, `/v1/models`, `/v1/health`, and `/v1/version`. | Both planes take the same `sk_live_` key, in two different headers. The hosting API takes `Authorization: Bearer sk_live_...`. Instance URLs take the raw key as `X-Agent37-Key: sk_live_...`, which leaves `Authorization` free for your own app running inside the instance. Mint keys in the [dashboard](https://www.agent37.com/dashboard/cloud/api-keys); each key is scoped to one workspace. On the hosting API the key selects your workspace. On an instance URL, the platform edge authenticates the key, verifies the instance belongs to your workspace, and forwards the request to the gateway running inside the instance. A key can carry an **IP allowlist**: up to 20 IPs or CIDR ranges (IPv4 and IPv6), edited per key in the [dashboard](https://www.agent37.com/dashboard/cloud/api-keys). A restricted key only works from those addresses, on both planes; anywhere else it gets `403 ip_not_allowed` with the observed IP in the message. An empty list means the key works from any IP. [Signed URLs](/docs/agents-api/urls) and [public ports](/docs/agents-api/public-ports) carry their own authorization and are not affected, so a browser link keeps working wherever it is opened. So you create an instance with one call to `api.agent37.com`, then talk to it at its own hostname: ```bash theme={null} curl https://ab12cd34ef.agent37.app/v1/responses \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "input": "Research the top 3 EV makers, write a memo." }' ``` Instance URLs require auth on every request: the `X-Agent37-Key` header for API calls, or a time-boxed [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls) to open a preview URL in a browser tab. An unauthenticated request gets a 401. ## The resource model | Concept | What it is | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Instance** | An isolated, persistent computer that runs your agent. Built from a [template](/docs/agents-api/templates). Lives until you delete it, metered per minute for what it holds: the full rate while running, disk alone while stopped or sleeping. Create one per end user. | | **Session** | One conversation on an instance. A message starts one; reuse its `session_id` to continue. An instance can hold many. | | **Response** | One agentic turn: your input, the agent's work, its reply. Stream it live, or reconnect to its stream by id. | **The agent is not the model.** The *template* installs the agent software, the *gateway* inside the instance runs your sessions on it (Hermes and OpenClaw are the live agents today; Claude Code and Codex are coming soon), and the *model* is the LLM the agent thinks with, chosen per turn as `model` + `provider`. ### How they fit together * Create an **instance** once per end user with `POST /v1/instances` on the hosting API. It keeps files, connected accounts, and memory across every session. An instance does not have to run around the clock: create it with `auto_sleep: true` and the platform checkpoints it to `sleeping` once it has been idle past its `idle_timeout_seconds`, billing disk alone while it sleeps (awake minutes bill at 4x the compute rate); any request to its URL wakes it transparently, usually well under a second. See [Auto-sleep](/docs/agents-api/instances#auto-sleep). * Start a **session** by sending a message to the instance's own URL: `POST https://{instanceId}.agent37.app/v1/responses`. Omit `session_id` and the gateway mints a new session; the reply carries the `session_id` you reuse to continue the thread. See [Sessions](/docs/agents-api/sessions). * Each message produces a **response**. Stream it live with `stream: true` (see [Streaming](/docs/agents-api/streaming)), or reconnect a dropped stream with `GET /v1/responses/{id}/stream`. ## Every port has a derivable URL Nothing about ports is declared per instance: * The **instance URL**, `https://{instanceId}.agent37.app`, routes to the template's `default_port` (`3737`, the gateway, unless the template says otherwise). On the default template, `agent37-hermes`, that makes it the chat URL. * Every other port is reachable at its **preview URL**, `https://{instanceId}-{port}.agent37.app`, for example `https://ab12cd34ef-9119.agent37.app`. * A port can also get a permanent unauthenticated URL; see [Public ports](/docs/agents-api/public-ports). See [Instance and preview URLs](/docs/agents-api/urls) for how routing and authentication work, and [Templates](/docs/agents-api/templates) for `default_port` on your own images. ## One wallet, per-instance caps Your workspace has exactly one pot of money: the wallet, funded by top-up from the [billing dashboard](https://www.agent37.com/dashboard/cloud/billing). Two things draw on it: * **Compute.** Each instance is metered per minute: the full rate while running, disk alone while stopped or sleeping. See [Billing](/docs/agents-api/billing). * **Managed usage.** Every instance gets managed LLM, Brave search, and Composio credentials, metered at cost as the agent uses them. They arrive as environment variables inside the container, so a [custom image](/docs/agents-api/custom-image) can use them too; see [Managed services in your image](/docs/agents-api/managed-services). Per-instance [budgets](/docs/agents-api/budgets) are caps, not money. A budget bounds how much managed spend an instance may pull from the wallet: a monthly cap that resets each UTC month (default \$0) plus one-time top-up headroom. Raising a cap moves no funds; an instance with a generous cap and an empty wallet still gets refused. ## Conventions * **Instance ids** are bare 10-character lowercase alphanumerics, like `ab12cd34ef`. No prefixes. The id doubles as the DNS label in the instance URL. * **Session and response ids** are 32-character hex strings minted by the gateway, like `7f3e0b6c52a949d2b1c4a8e9d0f31726`. * **Money** is integer micros: USD x 1e6, in `*_micros` fields. * **Timestamps** are epoch seconds on the hosting API and epoch milliseconds on the agent API. The [App integrations](/docs/agents-api/integrations) endpoints are the exception: they pass Composio's native shapes through unchanged, with millisecond timestamps. * **Lists** wrap results in `{ "data": [...] }`. Instance and session lists are newest first. (The App integrations endpoints again pass Composio's native paginated and connection shapes through instead.) An instance's `status` is one of `provisioning`, `running`, `stopping`, `stopped`, `starting`, `restarting`, `updating`, `sleeping`, `waking`, `failed`, `deleting`, or `deleted`; a response's `status` is `in_progress`, `completed`, `failed`, or `cancelled`. ## Next steps Fork a finished multi-tenant dashboard and rebrand it, the fastest way to ship. Create, size, and manage the agent's computer. The core call and its response shape. Pick a catalog agent or bring your own image. Cap each instance's managed spend. # Build a custom image Source: https://www.agent37.com/docs/agents-api/custom-image Write a Dockerfile, build the image on Agent37's infrastructure or import it from a public registry, and run instances from the pinned template. You can run an Agent37 instance from any Docker image. Register it as a [workspace template](/docs/agents-api/templates) in one of two ways: | Your image | Register with | What Agent37 needs | | -------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Not built yet, local, or private | A [cloud build](#build-it-in-the-cloud) | A folder with the `Dockerfile` and the files it `COPY`s. Agent37 runs the build; no local Docker needed. | | In a public registry | `image_ref` | A fully qualified reference that Agent37 can pull anonymously during registration. | A cloud build fetches everything itself, including the `FROM` base image and anything `RUN` downloads, so those sources must be publicly reachable. There is no build-secret or private-registry-credential support yet; if your base image is private, [contact support](mailto:support@agent37.com). A complete example lives in [agent37-platform/custom-agent-image](https://github.com/agent37-platform/custom-agent-image): a working agent app (auth, chat, files, integrations) whose agents run an image it builds itself from one `Dockerfile` and one skill folder. Click **Use this template**, edit the `Dockerfile`, run `npm run release:agent`. Smaller ready-to-build recipes live in the [Agent37 Cookbook](https://github.com/agent37-platform/examples), like [hermes-vnc-desktop](https://github.com/agent37-platform/examples/tree/main/custom-images/hermes-vnc-desktop), a live desktop view of the agent's browser. This page is the walkthrough; [Templates → build on the Hermes base image](/docs/agents-api/templates#build-on-the-hermes-base-image) is the reference for the contract. When you register a template, Agent37 copies your image once into private storage and pins it by digest. That snapshot is what every instance runs, so registering takes up to a few minutes for a large image. Re-pushing the same tag later does nothing: publish a new tag and [update the template](/docs/agents-api/templates#update-a-template), or re-run the [cloud build](#build-it-in-the-cloud). Your image is stored privately and never republished. ## 1. Choose a starting image To customize Hermes, build on [`ghcr.io/agent37-platform/hermes-base`](https://github.com/orgs/agent37-platform/packages/container/package/hermes-base). It includes Hermes, the gateway, Chromium, and the standard toolchain. This example adds a CLI: ```dockerfile theme={null} FROM ghcr.io/agent37-platform/hermes-base:latest USER root RUN apt-get update && apt-get install -y --no-install-recommends your-cli \ && rm -rf /var/lib/apt/lists/* USER node ``` Bake binaries into `/usr/local/bin` and everything else into `/usr/local` or `/opt`, never `/home/node` or `/home/linuxbrew`, which are masked at runtime. Keep the base `ENTRYPOINT`. See [the full contract](/docs/agents-api/templates#build-on-the-hermes-base-image). **Skills don't belong in the image.** Hermes reads them from `~/.hermes/skills`, which lives on the instance's persistent volume, so anything the image writes there is masked. Install them into a running instance instead, over [exec](/docs/agents-api/exec) or the [files API](/docs/agents-api/files): ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/instances//exec \ -H "Authorization: Bearer $AGENT37_API_KEY" -H "Content-Type: application/json" \ -d '{ "command": "mkdir -p ~/.hermes/skills/my-skill && echo '"'"''"'"' | base64 -d > ~/.hermes/skills/my-skill/SKILL.md" }' ``` An app that provisions agents does this once, right after create. The skill then persists across restarts and updates. You can also use an existing image or start from any base. Its main process must keep running. If it serves HTTP, bind it to `0.0.0.0` and note its listening port; you will pass that as `default_port` when you register the template. An image with no HTTP service can omit `default_port` and be driven through [exec](/docs/agents-api/exec), but it still needs a long-running `ENTRYPOINT` or `CMD`. Your built image freezes its base at build time. `:latest` is convenient while getting started; pin a dated Hermes base tag for reproducible production rebuilds. Find published tags [on GHCR](https://github.com/orgs/agent37-platform/packages/container/package/hermes-base), or read the current tag from `agent37-hermes`'s `image_ref` on `GET /v1/templates`. ## 2. Verify it locally (optional) You don't need Docker to publish; the [cloud build](#build-it-in-the-cloud) does the real build. If you have Docker, a local build is still the fastest way to test before publishing. Plain `docker build` stores the amd64 result in your local Docker, including on an Apple Silicon Mac: ```bash theme={null} docker build --platform linux/amd64 -t my-agent:v1 . docker image inspect my-agent:v1 --format '{{.Os}}/{{.Architecture}}' # linux/amd64 ``` Run the image locally if it is practical: registration validates the image, but only a running container proves that its entrypoint and service work. If you take the [public registry path](#import-a-public-registry-image), this local `linux/amd64` build is also the artifact you push. ## 3. Register the image You have two ways to get the image to Agent37. Either way the resulting image is capped at **8 GB decimal** (`8,000,000,000` bytes), and either way the platform copies it once into private storage at registration. Registration is synchronous and can take a few minutes for a large image. The returned `image_digest` identifies the immutable private copy every instance runs. Re-pushing a mutable source tag does not change that copy. ### Build it in the cloud Use this path when the image isn't in a public registry, or doesn't exist yet. You upload a small **build context** (the `Dockerfile` and the files it `COPY`s), and Agent37 builds the image on its own infrastructure: ```bash theme={null} export AGENT37_API_KEY=sk_live_... npx agent37 templates build . --name my-custom-agent ``` What it does: * Packs the directory (default `.`; it must have `Dockerfile` at its root) into a gzipped context, excluding `.git` and your `.dockerignore` patterns (plain patterns only; `!` negations are ignored). The context is capped at **100 MB**; it holds the Dockerfile's inputs, not the image, so it stays small. * **Everything else in the folder ships with the context**, a stray `.env` or key file included, and a `COPY . .` bakes it into the image. Check the folder, or add a `.dockerignore`, before you build. * Builds the image on Agent37's infrastructure and **streams the live build log** to your terminal. On failure the command exits non-zero with the failing step visible. * Publishes the result as the workspace template. `--name` defaults to the folder name; `--default-port ` sets the template's default port. Re-building an existing name publishes a new template revision; **existing instances never change**. * Ctrl-C does not cancel the build; it continues server-side and still publishes on success. Builds are free, run one at a time per workspace, and time out after 45 minutes. The built image is capped at 8 GB decimal, like any template image; if you need more, [contact support](mailto:support@agent37.com). Everything the build fetches, including the `FROM` base image and anything `RUN` downloads, must be publicly reachable; there is no build-secret or private-registry-credential support yet. A template published this way has an `image_digest` but no `image_ref`, because there is no public registry reference. To script the same flow without the CLI, see the raw contract in [Templates → build an image in the cloud](/docs/agents-api/templates#build-an-image-in-the-cloud). ### Import a public registry image Push the image to any public OCI registry, then register its fully qualified reference. For example: ```bash theme={null} docker tag my-agent:v1 ghcr.io/you/my-agent:v1 docker push ghcr.io/you/my-agent:v1 ``` On GHCR, make the package public after the first push (**Packages** → **Package settings** → **Change visibility** → **Public**). In CI, GitHub's `ubuntu-latest` runner is already `linux/amd64`, and the built-in `GITHUB_TOKEN` can push to `ghcr.io//` with `permissions: packages: write`. Tag the image with the commit sha and register that, never `latest`. Register a specific tag, not `latest`: ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/templates \ -H "Authorization: Bearer $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "my-custom-agent", "image_ref": "ghcr.io/you/my-agent:v1" }' ``` Agent37 needs anonymous access only while it copies the image. The template and its instances no longer depend on the source registry after registration. A future image update needs a new public tag. If your own image serves HTTP on port `8000`, declare it: pass `--default-port 8000` on the build, or include `"default_port": 8000` beside `name` and `image_ref`. The bare instance URL then routes to that port, and instance creation probes it at boot. Omit it for `hermes-base`, whose gateway uses the `3737` fallback, or for a long-running private sandbox with no HTTP service. ## 4. Create an instance Register once, then create instances from the template name: ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/instances \ -H "Authorization: Bearer $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template": "my-custom-agent" }' ``` The result is a standard Agent37 instance running your image: same [lifecycle](/docs/agents-api/instances), [exec](/docs/agents-api/exec), and routed URLs as any other. Confirm your CLI shipped, without needing a model: ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/instances//exec \ -H "Authorization: Bearer $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "command": "your-cli --version" }' ``` If the instance comes up `failed` instead of `running`, read its [logs](/docs/agents-api/logs). An entrypoint that exits, a missing binary, or a service that never listens on `default_port` are the usual causes. `exec` cannot help until the container is running. ## 5. Give it a model `hermes-base` is clean: it boots with no LLM provider wired. That does not mean the instance has no model available. Every instance, including one running an image you built from scratch, gets a working OpenAI-compatible endpoint and a Composio MCP server in its environment, as `AGENT37_MANAGED_TOKEN`, `AGENT37_LLM_PROXY_URL`, and `AGENT37_COMPOSIO_MCP_URL`. Pointing your agent at those is the shortest path to a running agent, costs no setup, and meters to the instance [budget](/docs/agents-api/budgets). [Managed services in your image](/docs/agents-api/managed-services) is the reference, and [pi-agent-image](https://github.com/agent37-platform/pi-agent-image) is a complete image that does it in two config files with no credentials in either. ### Bring your own model instead To run an instance on your own model, point Hermes at any OpenAI-compatible endpoint, your own proxy or a provider directly, by writing `~/.hermes/config.yaml` on the instance: ```yaml theme={null} model: provider: "custom:MyProvider" default: "moonshotai/kimi-k2.7-code" # the model id your endpoint serves custom_providers: - name: "MyProvider" base_url: "https://your-llm-proxy.example.com/v1" # must end in /v1 api_key: "your-proxy-token" api_mode: "chat_completions" model: "moonshotai/kimi-k2.7-code" ``` Your endpoint must serve the two OpenAI-compatible routes Hermes uses: `GET /v1/models` (to resolve the model id) and `POST /v1/chat/completions` (the turn). Anything that speaks them works: a provider directly, or a small proxy of your own that forwards to one with your key. Write the config over [exec](/docs/agents-api/exec) or the instance terminal; it lives on the persistent volume, so it survives restarts. Then [send a message](/docs/agents-api/chat) and the agent runs on your model. Want this as a finished app rather than a config file? [Use your own model](/docs/agents-api/byo-model) is a forkable kit that does exactly this per agent: your key behind a proxy, a revocable token per instance, and per-agent spend caps. Want chat to work out of the box on Agent37's managed model instead? Build `FROM ghcr.io/agent37-platform/hermes:` ([tags on GHCR](https://github.com/orgs/agent37-platform/packages/container/package/hermes)), which wires the managed model, and pass a [budget](/docs/agents-api/budgets) on create. ## Keep it current Your image freezes its base; rebuilding is how it picks up platform updates. Each template revision is an immutable snapshot, so a new build takes effect only after the template changes: re-run the [cloud build](#build-it-in-the-cloud) under the same name, or publish a **new registry tag** and [PATCH `image_ref`](/docs/agents-api/templates#update-a-template). Every successful build and every changed `image_ref` increments the template's automatic `revision`; a re-build counts even when its digest matches the previous image. PATCHing the same `image_ref` string deliberately reuses the existing snapshot and revision. Existing instances keep their installed `template_revision`. [Update each instance](/docs/agents-api/instances#update) to recreate it from the template's current revision. Anything on the persistent volume (`~/.hermes/skills`, `~/.hermes/config.yaml`, the agent's files) survives that update untouched, so a changed skill needs the same [exec](/docs/agents-api/exec) write again, not a rebuild. ## Troubleshooting | Error or symptom | What to check | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `build_failed` | A Dockerfile step failed. Read the build log: the CLI streams it, and `GET /v1/template-builds/{id}/logs` keeps it readable afterward; the failing step is at the end. | | `build_conflict` | One build runs at a time per workspace. Wait for the running build to finish, then start again. | | `build_timeout` | The build ran past the 45-minute limit. Trim the Dockerfile's slowest steps and retry. | | `image_too_large` | The built or imported image is over 8 GB decimal. Remove build caches and unnecessary layers, or contact support. | | `invalid_request` for `image_ref` | Use a fully qualified tag and confirm the registry allows anonymous pulls. For an image that isn't public, run a [cloud build](#build-it-in-the-cloud) from its Dockerfile instead. | | `image_ingest_failed` | The private copy failed on Agent37's side. Retry once by re-running the build or the registration, then contact `support@agent37.com`. | | Instance status is `failed` | Read [instance logs](/docs/agents-api/logs); confirm the main process stays alive and `default_port` listens when set. | # Custom domains Source: https://www.agent37.com/docs/agents-api/domains Serve every URL your workspace has under your own domain, by delegating a dedicated domain's nameservers. A **custom domain** is a workspace-level mirror of everything you serve on `agent37.app`. Register `acme-agents.com` once, and every URL your workspace has also answers under it: instance `ab12cd34ef` becomes reachable at `https://ab12cd34ef.acme-agents.com` with the same `X-Agent37-Key` and signed-URL authentication as its [instance URL](/docs/agents-api/urls), preview URLs work at `https://ab12cd34ef-8080.acme-agents.com`, and every [public port](/docs/agents-api/public-ports) hostname serves credential-free at `https://{hostname}.acme-agents.com`. Your `agent37.app` URLs keep working unchanged; the custom domain is an addition, not a replacement. Use a **dedicated domain** registered for this purpose, like `acme-agents.com` or `acme.sh`, not your main website's domain: you delegate the whole domain's DNS to us, and we host it from then on. One wildcard TLS certificate then covers your entire fleet, whether that's one instance or ten thousand, with nothing to provision per instance. Subdomains of an existing domain (`previews.acme.com`) cannot be registered. Setup is three steps: register, set the nameservers at your registrar, verify. ## Register the domain ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/domains \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "domain": "acme-agents.com" }' ``` ```python python theme={null} import requests r = requests.post( "https://api.agent37.com/v1/domains", headers={"Authorization": "Bearer sk_live_..."}, json={"domain": "acme-agents.com"}, ) print(r.json()["dns"]) ``` ```javascript node theme={null} const res = await fetch("https://api.agent37.com/v1/domains", { method: "POST", headers: { Authorization: "Bearer sk_live_...", "Content-Type": "application/json" }, body: JSON.stringify({ domain: "acme-agents.com" }), }); console.log((await res.json()).dns); ``` ```json response theme={null} { "domain": "acme-agents.com", "status": "pending", "dns": { "type": "NS", "name": "acme-agents.com", "nameservers": ["ada.ns.cloudflare.com", "rob.ns.cloudflare.com"] }, "created": 1751928000 } ``` The domain you registered, lowercased. What your workspace serves lives one label below it (`ab12cd34ef.acme-agents.com`). `pending` until verification passes, then `active`. Only an active domain routes. The nameservers to set at your domain's registrar: replace the domain's existing nameservers with the two in `dns.nameservers`. Unix seconds when the domain was registered. Custom domains are a paid feature: they unlock once your workspace's top-ups total \$100, the same tier that raises the instance limit to 50. Below it, registration returns `403 tier_limit`; email [vishnu@agent37.com](mailto:vishnu@agent37.com) to unlock them another way. Register the domain itself, `acme-agents.com`, not `*.acme-agents.com`: the wildcard is implied. The name must be a registrable domain you own; a subdomain of another domain returns `400 invalid_request`. A domain is claimable by exactly one workspace across the platform, first come, so a name another workspace holds returns `409 domain_exists`. A registration can only ever verify with control of the domain at its registrar, so squatting buys nothing; if a domain you own is already taken, email [vishnu@agent37.com](mailto:vishnu@agent37.com). ## Set the nameservers At the registrar where the domain is registered, replace its nameservers with the two from the response's `dns.nameservers`. That is the whole DNS setup: no individual records to manage, we host the domain's DNS from then on. Registrar nameserver changes usually land within minutes, occasionally hours. ## Verify Once the nameservers are set, verify: ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/domains/acme-agents.com/verify \ -H "Authorization: Bearer sk_live_..." ``` ```json response theme={null} { "domain": "acme-agents.com", "status": "active" } ``` Verification checks that the delegation has landed. It is idempotent and safe to poll: call it right after changing the nameservers, and again if it still answers `pending` (each call also nudges the delegation re-check, so polling genuinely speeds things up). The moment the status flips to `active`, the domain starts routing. TLS is one wildcard certificate covering every hostname under the domain, issued automatically when the domain activates and renewed forever after. A TLS error in the first minutes after activation means the certificate has not landed yet; wait and retry. There is nothing to provision per instance, at any fleet size. ## Use it Hit the mirrored instance URL exactly as you would the `agent37.app` one: ```bash curl theme={null} curl https://ab12cd34ef.acme-agents.com/v1/health \ -H "X-Agent37-Key: sk_live_..." ``` ```json response theme={null} { "ok": true, "agent": "hermes", "healthy": true, "hermes": true } ``` Authentication is identical to `agent37.app`: instance URLs and preview URLs (`ab12cd34ef-8080.acme-agents.com`) take an `X-Agent37-Key` header or a signed URL, public-port hostnames need no credential at all. A [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls)'s token is bound to the label it was minted for, not the domain, so a token minted for the default port also opens `https://ab12cd34ef.acme-agents.com/?a37_token=...`. Only labels your own workspace serves resolve under your domain. Any other label answers exactly as an unknown `agent37.app` URL would (`401` without a credential, `404` with one), so nothing another workspace runs can ever appear under your domain. Once a domain is active, instance reads expose the mirrored URLs as `domain_urls`, on the instance, on each public-port entry, and on minted [signed URLs](/docs/agents-api/urls#browser-access-with-signed-urls). Entries are ordered oldest domain first, and the dashboard's open and copy-link actions lead with that first domain: ```json theme={null} { "id": "ab12cd34ef", "url": "https://ab12cd34ef.agent37.app", "domain_urls": ["https://ab12cd34ef.acme-agents.com"], "public_ports": [ { "port": 8644, "url": "https://a1b2c3d4e5f6a7b8c9d0.agent37.app", "domain_urls": ["https://a1b2c3d4e5f6a7b8c9d0.acme-agents.com"], "prefix": null, "created": 1751928000 } ] } ``` ## List and delete `GET /v1/domains` lists the workspace's domains as `{ "data": [...] }`, each entry in the full shape above (including the `dns` block, so you can always re-read the nameservers to set). `GET /v1/domains/{domain}` reads one. ```bash curl theme={null} curl -X DELETE https://api.agent37.com/v1/domains/acme-agents.com \ -H "Authorization: Bearer sk_live_..." ``` ```json response theme={null} { "domain": "acme-agents.com", "deleted": true } ``` Deleting stops routing under the domain within seconds and releases its DNS hosting; your `agent37.app` URLs are unaffected. The delete acts once; repeating it returns `404`. ## Rules and limits * **Paid workspaces only.** Registering a domain requires total top-ups of \$100; below that, `POST /v1/domains` returns `403 tier_limit`. * **Up to 10 domains per workspace**, at no charge. Registering an eleventh returns `400 invalid_request`; a bad hostname or a domain under `agent37.app` or `agent37.com` (reserved) returns `400` too. * **Dedicated domains only.** The whole domain's DNS is delegated to us, so it cannot carry your website, mail, or other records. Register a domain just for this. * The path `/health` is answered by the platform edge itself on custom domains too, just as on `agent37.app`, and never reaches your app. * Traffic on a custom domain is ordinary instance traffic: same billing, and a request [wakes a sleeping instance](/docs/agents-api/urls#sleeping-instances-wake-on-request) the same way. * Reads, verifies, and deletes are uniform: a domain registered by another workspace returns the same `404` as one that does not exist. # Errors Source: https://www.agent37.com/docs/agents-api/errors Stable, machine-readable error codes on both planes: branch on the code, show the message. Every error uses a standard HTTP status code and returns a stable, machine-readable code in a JSON `error` field: an object with a `code` on both API catalogs, a flat string on transport failures between you and the gateway. Branch on the code, never on `message` or the HTTP status alone. There are two catalogs because there are two planes, plus a short list of transport errors. Both planes take the same `sk_live_` key, as `Authorization: Bearer` on the hosting API and as `X-Agent37-Key` on instance URLs, but their error envelopes differ: the Agent API adds optional `param`, `hint`, and `response_id` fields. See [Core concepts](/docs/agents-api/concepts) for the two planes. ## Hosting API errors Errors from `https://api.agent37.com/v1/*` always carry exactly `code` and `message`. There is no `param` or `hint` on this plane. ```json theme={null} { "error": { "code": "insufficient_balance", "message": "This instance costs $0.0068 per hour, metered per minute; creating it requires at least one day of balance ($0.1624). Add balance to your workspace and try again." } } ``` A stable, machine-readable identifier. Branch on this. A human-readable description. Safe to show, but do not parse it. ### Hosting API codes | Code | HTTP | When | | ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `invalid_api_key` | 401 | The `sk_live_` Bearer key is missing, malformed, or revoked, on any `/v1` path. | | `ip_not_allowed` | 403 | The key is valid but the request came from an IP outside the key's [IP allowlist](/docs/agents-api/concepts#two-planes-one-key). The `message` includes the observed IP. | | `invalid_request` | 400 | A request field is invalid: bad JSON, an unsupported resource shape, a direct image ref where a template name belongs, a [template](/docs/agents-api/templates) create with no `image_ref`, a not-publicly-pullable registry reference, starting a [template build](/docs/agents-api/templates#build-an-image-in-the-cloud) whose context is missing or over 100 MB, a lifecycle action in the wrong state, or an invalid [`update`](/docs/agents-api/instances#update) body (any field other than `template`, or a bad or unpublished [version pin](/docs/agents-api/templates#pin-a-template-version)). | | `image_too_large` | 400 | A [template](/docs/agents-api/templates)'s staged image is over the 8 GB decimal cap (`8,000,000,000` bytes). | | `forbidden` | 403 | A write to a system template. System templates are read-only. | | `not_found` | 404 | No instance or template with that id or name in your workspace, or an unknown `/v1` path. | | `insufficient_balance` | 402 | The wallet holds less than a day of the instance's running rate at create, or is negative at `start` of a `past_due` instance. | | `instance_limit_reached` | 409 | The workspace is at its instance limit: one instance on the free credit, 10 once you have topped up, 50 once top-ups total \$100, 200 once they total \$250. Email [vishnu@agent37.com](mailto:vishnu@agent37.com) to raise it further. | | `tier_limit` | 403 | Create or resize asks for a shape larger than your plan includes, or a [custom domain](/docs/agents-api/domains) is registered before your top-ups total \$100. Free workspaces (before your first top-up) run any template on the 2 vCPU / 4 GB shape; a top-up unlocks the 4/8 and 8/16 shapes. | | `shared_unavailable` | 403 | [Create](/docs/agents-api/instances#create-an-instance) asks for `"type": "shared"` on a workspace it isn't enabled for. Nothing is provisioned and nothing is charged; book the call in the message. | | `capacity_unavailable` | 409 | `start` or `resize`: no host in the fleet can fit this instance, or the resize increase, right now. | | `template_conflict` | 409 | A template create or rename targets a name that already exists. | | `build_conflict` | 409 | A [template build](/docs/agents-api/templates#build-an-image-in-the-cloud) `start` on a build that already started, or while another build is running in the workspace; one build runs at a time. | | `domain_exists` | 409 | A [custom domain](/docs/agents-api/domains) registration names a hostname already registered, by your workspace or any other. | | `try_again` | 409 | A transient state conflict: the instance is mid-transition, for example `stop` during the second or so while a sleep checkpoint is being written. Retry in a few seconds. | | `no_capacity` | 503 | Create only: no host can fit the requested shape right now. Safe to retry. | | `image_ingest_failed` | 502 | Registering a [template](/docs/agents-api/templates) failed while copying the image into private storage, for a reason on our side (not a bad image). Retry, or contact [support@agent37.com](mailto:support@agent37.com). | | `provisioning_failed` | 502/500 | A container or host operation failed (image pull, container start, lifecycle action). Creates debit nothing, so a failed create costs nothing. | Lookups are uniform: an id that belongs to another workspace returns the same 404 as an id that does not exist, and unknown `/v1` paths 404 only after your key is validated. Nothing about other workspaces leaks through error responses. A [template build](/docs/agents-api/templates#build-an-image-in-the-cloud) that fails after starting is not an HTTP error: `GET /v1/template-builds/{id}` returns `200` with `status: "failed"` and the reason (`build_failed`, `build_timeout`, `invalid_request`, `invalid_image_archive`, `image_too_large`, `image_ingest_failed`, or `internal_error`) in its `error` field. ## Agent API errors Errors from the gateway at `https://{instanceId}.agent37.app/v1/*` use the same envelope plus optional `param`, `hint`, and `response_id`. ```json theme={null} { "error": { "code": "validation_error", "message": "goal mode is not yet supported on this gateway.", "param": "mode", "hint": "Use mode \"chat\"." } } ``` The request field that was invalid. Present on `validation_error` when a specific field is at fault; a malformed JSON body has no `param`. A suggested next step, when one applies. On `session_busy`: the id of the response already running on the session. Reattach with `GET /v1/responses/{id}/stream` or cancel it. Treat it as optional; in a rare race the code arrives without it. ### Transport errors Auth, instance lookup, and routing happen on the platform between you and the gateway, and rejections there use a flat string instead of the envelope: `{"error": ""}`. Some carry a human-readable `message` (and, on a 401 with no credentials, a `docs` link); branch on the code, not on either. Check whether `error` is a string before reading `code`. | Code | HTTP | When | | ----------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `invalid_api_key` | 401 | No credential, or a key or signed URL that did not verify. The `message` says which, and how to authenticate a browser request. | | `ip_not_allowed` | 403 | The key is valid but the request came from an IP outside the key's [IP allowlist](/docs/agents-api/concepts#two-planes-one-key). The `message` includes the observed IP. Signed URLs are unaffected. | | `instance_suspended` | 402 | The instance is suspended for non-payment, so the request will not wake it. Top up the workspace wallet and retry. | | `not_found` | 404 | An instance URL that does not route: unknown, deleted, `failed`, or another workspace's. | | `container_unavailable` | 502 | The instance is not running, typically `stopped`. Start it and retry. | | `container_unreachable` | 502 | The instance is up but its service did not answer on the routed port, which is common briefly during a restart or update. Transient; retry. | | `upstream_unreachable` | 502 | The platform could not reach the instance's host. Transient; retry. | | `instance_saturated` | 503 | Too many concurrent requests in flight to this instance. Back off and retry. | | `host_mesh_not_ready` | 503 | The instance's host is still joining the platform network. Transient; retry. | | `wake_timeout` | 503 | A [wake](/docs/agents-api/urls#sleeping-instances-wake-on-request) of a sleeping instance did not finish within about 3 minutes. It keeps waking in the background; retry. | | `wake_failed` | 503 | A wake of a sleeping instance failed. It stays `sleeping`, and the next request tries again. | | `upstream_timeout` | 504 | A call produced no response headers for \~100 seconds, typically a wedged instance. The turn may still be running: recover the result through its session, and prefer `stream: true` to see progress. | | `internal_error` | 500 | Unexpected platform error. | ### Agent API codes | Code | HTTP | When | | -------------------- | ---- | ------------------------------------------------------------------------------------------------------------ | | `validation_error` | 400 | A request field is invalid (`param` names it), the body is not valid JSON, or `mode` is `"goal"` (reserved). | | `not_a_directory` | 400 | `GET /v1/files?path=` points at something that isn't a directory. | | `response_not_found` | 404 | No response with that id. | | `file_not_found` | 404 | No file or directory at that path (`GET /v1/files`, `GET /v1/files/content`, `PATCH /v1/files`). | | `not_found` | 404 | Unknown route on the instance URL. | | `rename_unsupported` | 405 | `PATCH /v1/sessions/{id}` against a harness that cannot rename sessions (no native editable title). | | `session_busy` | 409 | A response is already running on this session; on the 409, `error.response_id` usually names it. | | `title_conflict` | 409 | `PATCH /v1/sessions/{id}`: the requested title is already used by another session. | | `file_exists` | 409 | `PUT /v1/files/content` with `overwrite=false` when a file already exists at the path. | | `modified` | 412 | `PUT /v1/files/content` with `X-Expected-Mtime` when the file changed since you read it (lost-update guard). | | `payload_too_large` | 413 | A JSON request body exceeds 2 MB. The raw-body `PUT /v1/files/content` write is exempt. | | `rate_limited` | 429 | An upstream provider rate limit. Back off and retry. | | `agent_error` | 502 | The agent backend failed without a more specific code. | | `agent_unavailable` | 503 | The targeted harness is not available on this instance: not provisioned here, or down. | | `internal_error` | 500 | Unexpected gateway error. | The Agent API catalog is open-ended past this table. Failures inside the agent can surface provider-specific codes at 502 or 503 (for example a provider auth or quota error passes its raw code through, and an agent that is still warming up returns 503 with its own code). Treat any code you do not recognize as an agent-side failure: log it and show `message`. Not every failed turn is an HTTP error. `POST /v1/responses` never rejects because the agent failed mid-run: the call returns 200 with `status: "failed"` and the same error object in the response body's `error` field, and streams end with a `response.failed` event. Check `status`, not just the HTTP code. See [Send a message](/docs/agents-api/chat) and [Streaming](/docs/agents-api/streaming). ## Handle them Read `code`, then act by remedy: busy sessions get a cancel or a new session, transient codes get a retry with backoff, validation errors get fixed (read `param`), and anything unknown is agent-side. ```python python theme={null} import requests r = requests.post( "https://ab12cd34ef.agent37.app/v1/responses", headers={ "X-Agent37-Key": "sk_live_...", "Content-Type": "application/json", }, json={ "input": "Research the top 3 EV makers, write a memo.", "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", }, ) if not r.ok: error = r.json()["error"] if isinstance(error, str): raise RuntimeError(f"transport error: {error}") # flat platform error, e.g. container_unavailable code = error["code"] if code == "session_busy": ... # cancel the running response or start another session elif code == "rate_limited": ... # transient: back off and retry elif code == "validation_error": raise ValueError(f"{error.get('param')}: {error['message']}") else: ... # unknown codes are agent-side failures: log code, show message ``` ```javascript node theme={null} const res = await fetch("https://ab12cd34ef.agent37.app/v1/responses", { method: "POST", headers: { "X-Agent37-Key": "sk_live_...", "Content-Type": "application/json", }, body: JSON.stringify({ input: "Research the top 3 EV makers, write a memo.", session_id: "7f3e0b6c52a949d2b1c4a8e9d0f31726", }), }); if (!res.ok) { const { error } = await res.json(); if (typeof error === "string") { // flat platform error, e.g. "container_unavailable": see the transport table throw new Error(`transport error: ${error}`); } switch (error.code) { case "session_busy": // cancel the running response or start another session break; case "rate_limited": // transient: back off and retry break; case "validation_error": throw new Error(`${error.param}: ${error.message}`); default: // unknown codes are agent-side failures: log code, show message console.error(error.code, error.message); } } ``` The same grouping works on the Hosting API: `insufficient_balance` sends your user to the billing dashboard, `no_capacity` is retryable, and `invalid_request` means fix the request before retrying. `no_capacity` (503, hosting) and `rate_limited` (429, agent) are safe to retry with backoff. So are `try_again` (409, hosting) and a `provisioning_failed` create: creates debit nothing (the meter only starts once the instance runs), so retrying never double-bills. ## Codes worth a closer look The workspace wallet cannot cover a charge. You see it in three places: at create, when the wallet holds less than one day of the instance's running rate (a check, not a debit; the meter only starts once the instance runs); at `start`, when a `past_due` instance's workspace balance is still negative; and inside agent behavior, when a managed call (LLM, Brave search, Composio) finds the wallet empty. The fix is the same everywhere: top up the wallet at `https://www.agent37.com/dashboard/cloud/billing` (\$5 minimum, \$1000 max per top-up), and enable [automatic top-up](/docs/agents-api/billing#automatic-top-up) so it does not recur. See [Billing](/docs/agents-api/billing). The wallet has funds, but this instance has used up its own managed-spend budget: the monthly cap is consumed and no one-time top-up headroom remains. Only managed calls are refused; the instance keeps running and compute billing is unaffected. Raise the cap with `PATCH /v1/instances/{id}/budget` or add headroom with `POST /v1/instances/{id}/budget/top-up`. See [Budgets](/docs/agents-api/budgets). You will not see this code on Hosting API calls: it surfaces when the agent's managed calls are refused mid-turn. A session runs one response at a time. Posting new input while a turn is in flight returns this, usually with the running response's id in `error.response_id`. Three ways out: reattach to the running turn with `GET /v1/responses/{response_id}/stream`, cancel it with `POST /v1/responses/{response_id}/cancel` (best effort; a finished response just returns its terminal state), or start a fresh session by omitting `session_id`. If the field is absent (a rare race, or an older gateway), read `active_response_id` from `GET /v1/sessions/{id}` instead. See [Sessions](/docs/agents-api/sessions). Three different walls, three different fixes. `instance_limit_reached` (409, create): the workspace is at its instance limit (one instance on the free credit, 10 once you have topped up, 50 once top-ups total \$100, 200 once they total \$250); delete instances you no longer need, or email [vishnu@agent37.com](mailto:vishnu@agent37.com) to raise the ceiling. `no_capacity` (503, create): no host can fit the requested shape right now; retry with backoff or pick a smaller shape, and a create that fails this way does not keep your money. `capacity_unavailable` (409, start or resize): the host that holds the instance's disk cannot fit it, and no other host has room to take it either (when the home host is full, the platform moves the instance to one with room before it ever fails); retry later. See [Instances](/docs/agents-api/instances). Both 402 reasons are billing limits, not bugs. When a managed call is refused mid-turn, the refusal shows up in agent behavior (the turn fails or the agent reports it); the instance itself never goes down over managed spend. # Examples Source: https://www.agent37.com/docs/agents-api/examples Everything you can build on the Agent37 API in one table: chat apps, a branded dashboard, your own model or Composio, custom images, webhooks, and a live agent desktop, most with a forkable repo. Every guide in this section ends with something running, and almost every one comes with a public repo. There are two ways in: * **Fork a finished app** when you want to ship fast: point it at your `sk_live_` key, rebrand, deploy. * **Build it yourself** when you want the API pattern: the guides walk through the exact calls. The table runs simplest first: start with a chat UI, then a full product, then swap in your own model and integrations, then change the agent itself. | I want to… | Guide | Code | | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Start simple: a chat UI on one always-on agent, replies streamed token by token | [Build a chat app](/docs/agents-api/chat-app) | [hermes-chat](https://github.com/agent37-platform/examples/tree/main/hermes-chat) | | Ship a branded product: a multi-tenant dashboard with chat, files, app integrations, and one-click terminal access | [White-label dashboard](/docs/agents-api/white-label) | [starter-kit](https://github.com/agent37-platform/starter-kit) | | Run agents on **my own model keys** instead of the managed LLM: my models, a token per agent, my spend caps | [Use your own model](/docs/agents-api/byo-model) | [hermes-byo-model](https://github.com/agent37-platform/hermes-byo-model) | | Run integrations on **my own Composio**: my API key, my users' connected accounts, my metering | [Use your own Composio](/docs/agents-api/composio) | [hermes-openclaw-composio](https://github.com/agent37-platform/hermes-openclaw-composio) | | Give the agent my own tools and skills: a custom Docker image | [Build a custom image](/docs/agents-api/custom-image) | [custom-agent-image](https://github.com/agent37-platform/custom-agent-image) | | Run a different agent entirely (the [pi](https://pi.dev) coding agent) on the managed LLM and Composio | [Managed services in your image](/docs/agents-api/managed-services) | [pi-agent-image](https://github.com/agent37-platform/pi-agent-image) | | Give Hermes a public URL for webhook deliveries | [Hermes webhooks](/docs/agents-api/hermes-webhooks) | None | | Watch my agent's desktop **live**: a browser view of it clicking and typing, embeddable in my own app | [Build a custom image](/docs/agents-api/custom-image) | [hermes-vnc-desktop](https://github.com/agent37-platform/examples/tree/main/custom-images/hermes-vnc-desktop) | All the runnable folders above live in the [Agent37 Cookbook](https://github.com/agent37-platform/examples): example apps, ready-to-build custom images, and pointers to the full apps. New examples land there as they ship. New to the API itself? Read [Core concepts](/docs/agents-api/concepts) first (one key, two planes), then come back and pick a row. # Run commands Source: https://www.agent37.com/docs/agents-api/exec Run any shell command inside an instance from your backend, the escape hatch for anything the API does not wrap. `POST /v1/instances/{id}/exec` runs a shell command inside the instance, straight from your backend. It is the escape hatch for anything the API does not wrap as its own call. The command runs through `sh -c` as the image's default user, on the same box the agent works on, so it sees the agent's files, tools, and credentials. ## Request The shell command to run inside the instance. It is passed to `sh -c`, so pipes, redirects, and `&&` chains all work. It must not contain a null byte, which no shell can carry: one returns `400 invalid_request`. Only `running` instances accept commands. Exec against any other status returns `400 invalid_request` (a deleted instance returns `404 not_found`), and it does not wake a `sleeping` instance: [start](/docs/agents-api/instances#start) it, or hit one of its URLs, first. If the platform cannot reach the instance at all, you get `502 provisioning_failed`. ## Response A command that runs but exits nonzero is a normal result: you get `200` with its `exit_code`, `stdout`, and `stderr`. Errors are reserved for the platform, not your command. The command's exit code. Nonzero is still a `200`; read this to branch. Standard output, capped at 512 KB. See `truncated`. Standard error, with its own separate 512 KB cap. `true` when either stream spilled past its 512 KB cap. The middle of the output is cut and a truncation marker is left in its place. `exit_code` values 125, 126, and 127 may come from the container runtime rather than your command, for example 127 when the binary is not found. A command runs for up to 280 seconds, after which the call fails with `502 provisioning_failed`; start longer jobs in the background (`nohup ... &`) and poll with a second exec. ## Example ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/exec \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "command": "node --version" }' ``` ```python python theme={null} import requests resp = requests.post( "https://api.agent37.com/v1/instances/ab12cd34ef/exec", headers={"Authorization": "Bearer sk_live_..."}, json={"command": "node --version"}, ) result = resp.json() print(result["exit_code"], result["stdout"]) ``` ```javascript node theme={null} const resp = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/exec", { method: "POST", headers: { Authorization: "Bearer sk_live_...", "Content-Type": "application/json", }, body: JSON.stringify({ command: "node --version" }), } ); const result = await resp.json(); console.log(result.exit_code, result.stdout); ``` ```json response theme={null} { "exit_code": 0, "stdout": "v24.2.0\n", "stderr": "", "truncated": false } ``` ## Build on exec Anything the API does not wrap as its own endpoint, you build on `exec`. For moving files, prefer the instance's own [files endpoints](/docs/agents-api/files) at `https://{instanceId}.agent37.app` (`PUT /v1/files/content` to upload, `GET /v1/files/content` to download), but a quick text read works over exec too. A "Download the report" button in your product can be one exec call that reads the file the agent wrote: ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/exec \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "command": "cat ~/reports/ev-makers-memo.md" }' ``` Pushing a file in is the same trick in reverse. Encode it on your side and decode it inside the instance: ```bash curl theme={null} curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/exec \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "command": "mkdir -p ~/data && echo aGVsbG8sd29ybGQK | base64 -d > ~/data/ev-prices.csv" }' ``` For binary or large files, use the [files endpoints](/docs/agents-api/files) on the instance URL instead, where `GET /v1/files/content` streams a download of any size with no 512 KB cap, or stage them at a URL your backend controls and `curl` them down from inside the instance. # Files Source: https://www.agent37.com/docs/agents-api/files Browse, read, write, move, delete, and download whole folders on the instance's disk, straight from the instance URL. Files live on the instance's disk. A file's absolute `path` is its identity: there are no file ids, so the path you list is the path you read, write, move, or delete. You write a file with one call, attach the returned `path` to [a message](/docs/agents-api/chat), and download anything the agent produces by path. The base URL is your instance URL, `https://{instanceId}.agent37.app`, with the same `sk_live_` key as every other call, sent as the `X-Agent37-Key` header. This page uses `https://ab12cd34ef.agent37.app`. The agent's workspace, where it reads and writes by default, is `/home/user/.agent37-gateway/workspace`, and that is the default directory for a list with no `path`. These calls are not jailed to the workspace. The `sk_live_` key is the instance root: any path the key can reach on the instance's filesystem is fair game, with `~` expanding to the agent's home. Treat the key accordingly. Every timestamp here is `modified`, the file's mtime in **epoch milliseconds**, following the Agent API convention (the Hosting API uses seconds). It is a number, not an ISO string. ## The file entry List responses and every write return the same `FileEntry` shape, so the `path` you get back from a write is ready to use on the next call. The basename, e.g. `leads.csv`. The resolved absolute path on the instance. This is the identity you pass to every other call and to `files` on [`POST /v1/responses`](/docs/agents-api/chat). `file`, `directory`, `symlink`, or `other` (sockets, devices, FIFOs). Size in bytes; `null` for directories. Last-modified time (mtime) in epoch milliseconds. `true` when the name starts with `.`. ## List a directory `GET /v1/files` lists one directory level. Omit `path` to list the agent's workspace; pass an absolute path or a `~/` path to list anywhere the key can reach. Entries are sorted directories first, then by name case-insensitively. The directory to list. Optional; defaults to the agent's workspace, `/home/user/.agent37-gateway/workspace`. Accepts absolute and `~/` paths. A path that exists but is not a directory returns `400 not_a_directory`. ```bash curl theme={null} curl -G https://ab12cd34ef.agent37.app/v1/files \ -H "X-Agent37-Key: sk_live_..." \ --data-urlencode "path=~/.agent37-gateway/workspace" ``` ```python python theme={null} import requests listing = requests.get( "https://ab12cd34ef.agent37.app/v1/files", headers={"X-Agent37-Key": "sk_live_..."}, params={"path": "~/.agent37-gateway/workspace"}, ).json() ``` ```javascript node theme={null} const listing = await (await fetch( "https://ab12cd34ef.agent37.app/v1/files?" + new URLSearchParams({ path: "~/.agent37-gateway/workspace" }), { headers: { "X-Agent37-Key": "sk_live_..." } }, )).json(); ``` ```json response theme={null} { "path": "/home/user/.agent37-gateway/workspace", "parentPath": "/home/user/.agent37-gateway", "entries": [ { "name": "reports", "path": "/home/user/.agent37-gateway/workspace/reports", "type": "directory", "size": null, "modified": 1781049600000, "hidden": false }, { "name": "leads.csv", "path": "/home/user/.agent37-gateway/workspace/leads.csv", "type": "file", "size": 18244, "modified": 1781049642000, "hidden": false } ], "truncated": false } ``` The resolved absolute path of the directory you listed. The parent directory's absolute path, or `null` at the filesystem root. The directory's immediate children as [`FileEntry`](#the-file-entry) objects. One level only; this never recurses. `true` when the directory holds more than 1000 entries; only the first 1000 (after sorting) are returned. ## Read, preview, or download a file `GET /v1/files/content?path=…` streams a file off the instance, typically one the agent told you it wrote. Any size; the 512 KB [exec](/docs/agents-api/exec) output cap does not apply here. The `Content-Type` is set from the file extension. The file to read. Accepts absolute and `~/` paths. A missing or empty `path`, or a path that is not a regular file, returns `400 validation_error`; no file at the path returns `404 file_not_found`. `attachment` sends `Content-Disposition: attachment` so a browser downloads the file. `inline` sends `Content-Disposition: inline` so a browser renders it (useful for previews). ```bash curl theme={null} curl -G https://ab12cd34ef.agent37.app/v1/files/content \ -H "X-Agent37-Key: sk_live_..." \ --data-urlencode "path=~/.agent37-gateway/workspace/reports/ev-makers-memo.md" \ -o ev-makers-memo.md ``` ```python python theme={null} import requests r = requests.get( "https://ab12cd34ef.agent37.app/v1/files/content", headers={"X-Agent37-Key": "sk_live_..."}, params={"path": "~/.agent37-gateway/workspace/reports/ev-makers-memo.md"}, ) open("ev-makers-memo.md", "wb").write(r.content) ``` ```javascript node theme={null} import fs from "node:fs"; const res = await fetch( "https://ab12cd34ef.agent37.app/v1/files/content?" + new URLSearchParams({ path: "~/.agent37-gateway/workspace/reports/ev-makers-memo.md", }), { headers: { "X-Agent37-Key": "sk_live_..." } }, ); await fs.promises.writeFile( "ev-makers-memo.md", Buffer.from(await res.arrayBuffer()), ); ``` Serving an agent-produced file `inline` runs it on **your** origin. HTML, SVG, and similar can execute scripts in the page that opens them, so an instance whose agent writes attacker-controlled content can run code against your users. Render untrusted files in a sandboxed frame (`