# 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 (`
## Download a folder
`GET /v1/files/archive?path=…` streams a whole directory as a gzipped tar (`.tar.gz`), the one call to pull a tree instead of walking it file by file. The archive is built on the fly and streamed, so any size works and the 512 KB [exec](/docs/agents-api/exec) output cap does not apply. It unpacks to a single top-level folder named after the directory you packed.
The directory to archive. Optional; defaults to the agent's workspace, `/home/user/.agent37-gateway/workspace`. Accepts absolute and `~/` paths. No directory at the path returns `404 file_not_found`; a path that exists but is not a directory returns `400 not_a_directory`.
The response is `Content-Type: application/gzip` and a `Content-Disposition: attachment` whose download filename is the packed directory's name plus `.tar.gz` (characters outside `A–Z a–z 0–9 . _ -` and space are stripped, and an empty result falls back to `archive`). Symlinks are stored as links, not followed, so the archive never inlines a link target's bytes.
```bash curl theme={null}
curl -G https://ab12cd34ef.agent37.app/v1/files/archive \
-H "X-Agent37-Key: sk_live_..." \
--data-urlencode "path=/home/user/.agent37-gateway/workspace/reports" \
-o reports.tar.gz
```
```python python theme={null}
import requests
r = requests.get(
"https://ab12cd34ef.agent37.app/v1/files/archive",
headers={"X-Agent37-Key": "sk_live_..."},
params={"path": "/home/user/.agent37-gateway/workspace/reports"},
)
open("reports.tar.gz", "wb").write(r.content)
```
```javascript node theme={null}
import fs from "node:fs";
const res = await fetch(
"https://ab12cd34ef.agent37.app/v1/files/archive?" +
new URLSearchParams({
path: "/home/user/.agent37-gateway/workspace/reports",
}),
{ headers: { "X-Agent37-Key": "sk_live_..." } },
);
await fs.promises.writeFile("reports.tar.gz", Buffer.from(await res.arrayBuffer()));
```
Expand it with `tar -xzf reports.tar.gz`. There is no folder-upload counterpart: to upload a tree, recreate it with per-file `PUT /v1/files/content` calls; each creates any missing parent directories (`mkdir -p`).
## Write a file
`PUT /v1/files/content?path=…` writes the **raw request body** to `path`. This is the one call for create, overwrite, edit, and upload. It is not multipart: the body is the file's exact bytes. Missing parent directories are created (`mkdir -p`). The response is the written file's [`FileEntry`](#the-file-entry).
Where to write. Accepts absolute and `~/` paths; parent directories are created as needed. A missing or empty `path` returns `400 validation_error`.
`true` replaces an existing file. `false` makes the write fail with `409 file_exists` if a file is already at `path`.
Optional optimistic-concurrency guard, epoch milliseconds. When the file exists and its `modified` differs from this value, the write fails with `412 modified`, meaning someone changed it since you read it. Ignored when the file does not exist (the write is treated as a create).
```bash curl theme={null}
curl -X PUT "https://ab12cd34ef.agent37.app/v1/files/content?path=/home/user/.agent37-gateway/workspace/leads.csv" \
-H "X-Agent37-Key: sk_live_..." \
-H "Content-Type: text/csv" \
--data-binary @leads.csv
```
```python python theme={null}
import requests
entry = requests.put(
"https://ab12cd34ef.agent37.app/v1/files/content",
headers={"X-Agent37-Key": "sk_live_..."},
params={"path": "/home/user/.agent37-gateway/workspace/leads.csv"},
data=open("leads.csv", "rb"),
).json()
```
```javascript node theme={null}
import fs from "node:fs";
const entry = await (await fetch(
"https://ab12cd34ef.agent37.app/v1/files/content?" +
new URLSearchParams({
path: "/home/user/.agent37-gateway/workspace/leads.csv",
}),
{
method: "PUT",
headers: { "X-Agent37-Key": "sk_live_..." },
body: await fs.promises.readFile("leads.csv"),
},
)).json();
```
```json response theme={null}
{
"name": "leads.csv",
"path": "/home/user/.agent37-gateway/workspace/leads.csv",
"type": "file",
"size": 18244,
"modified": 1781049642000,
"hidden": false
}
```
**Attach a file to a turn.** Write the bytes, then pass the returned `path` in the `files` array on [`POST /v1/responses`](/docs/agents-api/chat):
```bash theme={null}
curl https://ab12cd34ef.agent37.app/v1/responses \
-H "X-Agent37-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"input": "Summarize the attached spreadsheet.",
"files": ["/home/user/.agent37-gateway/workspace/leads.csv"]
}'
```
Each entry must name an existing file on the instance, or the call returns `400 validation_error`.
## Delete a file or directory
`DELETE /v1/files?path=…` removes the path recursively and by force, like `rm -rf`: a directory and everything under it goes in one call. There is no confirmation and no guard, so a wrong `path` is unrecoverable. A symlink is removed itself, not followed. The response is `{ "ok": true }`.
The file or directory to delete. Accepts absolute and `~/` paths. A missing or empty `path` returns `400 validation_error`.
```bash curl theme={null}
curl -X DELETE -G https://ab12cd34ef.agent37.app/v1/files \
-H "X-Agent37-Key: sk_live_..." \
--data-urlencode "path=/home/user/.agent37-gateway/workspace/reports"
```
```python python theme={null}
import requests
requests.delete(
"https://ab12cd34ef.agent37.app/v1/files",
headers={"X-Agent37-Key": "sk_live_..."},
params={"path": "/home/user/.agent37-gateway/workspace/reports"},
)
```
```javascript node theme={null}
await fetch(
"https://ab12cd34ef.agent37.app/v1/files?" +
new URLSearchParams({
path: "/home/user/.agent37-gateway/workspace/reports",
}),
{ method: "DELETE", headers: { "X-Agent37-Key": "sk_live_..." } },
);
```
```json response theme={null}
{ "ok": true }
```
Delete is recursive and unguarded. It removes whatever the key can reach, including directories full of files, with no undo. Double-check `path` before you send it.
## Rename or move a file
`PATCH /v1/files` renames or moves a path with `fs.rename`, taking a body of `{ "from", "to" }`. The OS decides the edge cases (overwriting an existing `to`, moving into a directory, crossing devices), so behavior matches a shell `mv`. The response is the [`FileEntry`](#the-file-entry) of the new path.
The current path. Accepts absolute and `~/` paths. Empty returns `400 validation_error`.
The new path. Accepts absolute and `~/` paths. Empty returns `400 validation_error`.
```bash curl theme={null}
curl -X PATCH https://ab12cd34ef.agent37.app/v1/files \
-H "X-Agent37-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "/home/user/.agent37-gateway/workspace/leads.csv",
"to": "/home/user/.agent37-gateway/workspace/archive/leads.csv"
}'
```
```python python theme={null}
import requests
entry = requests.patch(
"https://ab12cd34ef.agent37.app/v1/files",
headers={"X-Agent37-Key": "sk_live_..."},
json={
"from": "/home/user/.agent37-gateway/workspace/leads.csv",
"to": "/home/user/.agent37-gateway/workspace/archive/leads.csv",
},
).json()
```
```javascript node theme={null}
const entry = await (await fetch("https://ab12cd34ef.agent37.app/v1/files", {
method: "PATCH",
headers: {
"X-Agent37-Key": "sk_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "/home/user/.agent37-gateway/workspace/leads.csv",
to: "/home/user/.agent37-gateway/workspace/archive/leads.csv",
}),
})).json();
```
```json response theme={null}
{
"name": "leads.csv",
"path": "/home/user/.agent37-gateway/workspace/archive/leads.csv",
"type": "file",
"size": 18244,
"modified": 1781049642000,
"hidden": false
}
```
## Create a directory
`POST /v1/files/dir?path=…` creates a directory and any missing parents (`mkdir -p`). It is idempotent: a path that already exists returns its [`FileEntry`](#the-file-entry) rather than erroring.
The directory to create. Accepts absolute and `~/` paths; parents are created as needed. A missing or empty `path` returns `400 validation_error`.
```bash curl theme={null}
curl -X POST -G https://ab12cd34ef.agent37.app/v1/files/dir \
-H "X-Agent37-Key: sk_live_..." \
--data-urlencode "path=/home/user/.agent37-gateway/workspace/archive"
```
```python python theme={null}
import requests
entry = requests.post(
"https://ab12cd34ef.agent37.app/v1/files/dir",
headers={"X-Agent37-Key": "sk_live_..."},
params={"path": "/home/user/.agent37-gateway/workspace/archive"},
).json()
```
```javascript node theme={null}
const entry = await (await fetch(
"https://ab12cd34ef.agent37.app/v1/files/dir?" +
new URLSearchParams({
path: "/home/user/.agent37-gateway/workspace/archive",
}),
{ method: "POST", headers: { "X-Agent37-Key": "sk_live_..." } },
)).json();
```
```json response theme={null}
{
"name": "archive",
"path": "/home/user/.agent37-gateway/workspace/archive",
"type": "directory",
"size": null,
"modified": 1781049600000,
"hidden": false
}
```
## The loop
The common cycle is write, attach, fetch:
1. `PUT /v1/files/content?path=…` with the input bytes; keep the returned `path`.
2. `POST /v1/responses` with your `input` and that path in `files`.
3. When the agent replies that it wrote a file, `GET /v1/files/content?path=…` to fetch it, or `GET /v1/files` to browse what it left behind.
# Health & version
Source: https://www.agent37.com/docs/agents-api/health
Probe whether an instance's agent is ready, and read the gateway build.
Both endpoints live on the instance URL, not the hosting API: the base is `https://{instanceId}.agent37.app`, with the same `sk_live_` key sent as the `X-Agent37-Key` header. See [Instance and preview URLs](/docs/agents-api/urls).
## Health
`GET /v1/health` returns `{ "ok": true, "agent": "hermes", "healthy": true, "hermes": true }`. `ok` is true whenever the gateway is up; `agent` is the harness that was probed, and `healthy` reports whether that harness behind it is reachable. By default it probes the instance's configured harness; pass `?agent=`, `hermes` or `openclaw`, to probe a specific one (an unknown value is `400 validation_error`, and an empty value is the same as omitting it). An unreachable or unprovisioned harness reports as `healthy: false` with a `200`. When Hermes is probed, the body also carries a `hermes` field mirroring `healthy`, kept for backward compatibility; other harnesses omit it. Use it as a readiness probe after [create or start](/docs/agents-api/instances): the create call returns as soon as the instance's computer is up, while the agent inside is still booting.
```bash curl theme={null}
curl https://ab12cd34ef.agent37.app/v1/health \
-H "X-Agent37-Key: sk_live_..."
```
```json response theme={null}
{ "ok": true, "agent": "hermes", "healthy": true, "hermes": true }
```
## Version
`GET /v1/version` returns the gateway build, e.g. `{ "name": "agent37-gateway", "version": "0.1.3" }`.
# Hermes webhooks
Source: https://www.agent37.com/docs/agents-api/hermes-webhooks
Give Hermes a secure public webhook URL without exposing its Agent API.
Hermes receives webhook events on port `8644`, which Agent37 keeps credential-protected by default. This page covers the Agent37-specific step: giving that port a stable public URL without exposing the Agent API on port `3737`.
For Hermes route configuration, payload templates, delivery targets, and supported signatures, use the [Hermes webhook guide](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks).
## 1. Create the Hermes subscription
Open the Hermes dashboard on port `9119` with a [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls), then select **Webhooks**. Enable the receiver if needed and create a subscription. Agent37 manages the Hermes gateway restart.
Hermes shows a local URL and a signing secret when the subscription is created:
```text theme={null}
http://localhost:8644/webhooks/call-ended
```
Copy the secret when Hermes shows it. Keep the `/webhooks/call-ended` path; replace only the local origin below.
## 2. Give port 8644 a named URL
Add a `prefix` to make the hostname readable and stable:
```bash theme={null}
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/public-ports \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "port": 8644, "prefix": "webhooks" }'
```
```json theme={null}
{
"port": 8644,
"url": "https://webhooks-ab12cd34ef.agent37.app",
"domain_urls": [],
"prefix": "webhooks",
"created": 1783900800
}
```
A prefix produces `{prefix}-{instanceId}.agent37.app`; it does not claim a global bare hostname. Omit `prefix` if you prefer a random, unguessable hostname. See [Public ports](/docs/agents-api/public-ports) for creation-time configuration, rotation, and limits.
## 3. Replace the local origin
Combine the returned Agent37 origin with the path Hermes showed:
```text theme={null}
https://webhooks-ab12cd34ef.agent37.app/webhooks/call-ended
```
Configure the external service with this URL and the Hermes signing secret. The public URL supplies reachability; Hermes still authenticates the delivery using the signature rules in its documentation.
Test the subscription locally from the instance terminal, then use the provider's test-delivery feature:
```bash theme={null}
hermes webhook test call-ended
```
Do not test with `{url}/health`. Agent37 reserves `/health` at the edge, so that path does not reach Hermes.
# Instances
Source: https://www.agent37.com/docs/agents-api/instances
Create, size, and manage the persistent computer that runs your agent.
An instance is a persistent, isolated computer running your agent. Create one per end user. The call is synchronous: when `status` is `running`, the instance's computer is up and the agent inside finishes booting moments later. Poll `GET /v1/health` on the instance URL until it answers `{ "ok": true }`, then message it at `https://{id}.agent37.app/v1/responses` (see [Send a message](/docs/agents-api/chat) and [Instance and preview URLs](/docs/agents-api/urls)).
Creating an instance requires one day of compute at its running rate in your workspace wallet, but debits nothing: the balance check is the create gate (below it, the create fails with `402 insufficient_balance` and nothing is provisioned), and the meter only starts when the instance first reaches `running`. How many instances you can run at once is set by your [instance limit](/docs/agents-api/billing#instance-limits), which rises as you top up. See [Billing](/docs/agents-api/billing).
## Create an instance
`POST /v1/instances` returns `201` with the full instance object once `status` is `running`. Every field is optional, so a `POST` with no body works: you get the default template (`agent37-hermes`) on the smallest shape, 2 vCPU / 4 GB, dedicated.
A template name. `agent37-hermes` (full Hermes, with browser and desktop) is the default; `agent37-hermes-small` (the same agent on a lean image, no browser or desktop) and `agent37-openclaw` (OpenClaw, with a headless browser) are the other system templates. You can also pass one of your own [workspace templates](/docs/agents-api/templates) by name. Any name takes an optional `@` to [pin a published release](/docs/agents-api/templates#pin-a-template-version): a tag on system templates (`agent37-hermes@2026.07.02b`), a revision number on workspace templates (`my-agent@2`). Pin when your creates must be reproducible; the bare name follows the latest release. Unknown names and unpublished versions return `400 invalid_request`. Direct image references are rejected with `400`: register a template first, then pass its name.
The instance shape, for example `{ "cpu": 2, "memory": 4, "disk": 6 }`. Omitted, it uses the smallest shape, 2 vCPU / 4 GB. Every template offers the same three shapes below. Free workspaces (before your first top-up) run the 2 vCPU / 4 GB shape; the larger 4/8 and 8/16 shapes return `403 tier_limit` until you top up. Disk is any whole number of GB within the shape's range, and defaults to the range minimum when omitted. Any other combination returns `400 invalid_request` listing the valid shapes.
`dedicated` reserves the instance's whole shape around the clock. `shared` bills the shape's monthly average instead and is enabled per workspace: until yours is, create returns `403 shared_unavailable` with a link to set it up. Start dedicated; reach for `shared` to optimize cost. See [Dedicated and shared instances](/docs/agents-api/billing#dedicated-and-shared-instances).
An opaque tag for your own attribution, typically your end user's id. Stored, never interpreted, echoed back on the instance object.
A label for the instance.
Your own key/value pairs. Stored, never interpreted.
Environment variables for the container, as string key/value pairs. Set once at create and replayed on every restart, update, and wake. Up to 32 entries; keys are uppercase letters, digits, and underscores starting with a letter; values are strings of up to 4096 characters. See [Environment variables](#environment-variables).
Caps on this instance's managed usage (managed LLM, Brave search, and Composio calls), in micros (millionths of a dollar): `monthly_cap_micros` resets each UTC month, `credit_micros` adds one-time headroom that persists until spent. Both default to `0`, so managed calls are refused until you raise one. These are ceilings, not money; spend still draws the workspace wallet. See [Budgets](/docs/agents-api/budgets).
Opt the instance into [auto-sleep](#auto-sleep): once no bytes have moved through its URLs for `idle_timeout_seconds`, it is checkpointed to `sleeping` and bills disk alone until a request wakes it. Its awake minutes bill at 4x the compute rate.
How long the instance must be idle before it sleeps, in seconds. An integer from `60` to `86400` (one minute to one day). Only meaningful with `auto_sleep: true`.
Ports to expose at permanent unauthenticated URLs, each `{ port, prefix? }`, for webhooks and other callers that can't send a credential. See [Public ports](/docs/agents-api/public-ports), or follow the end-to-end [Hermes webhook setup](/docs/agents-api/hermes-webhooks) for port `8644`.
### Shapes and pricing
| cpu | memory | disk | Price at the default disk |
| --- | ------ | -------- | ------------------------------ |
| 2 | 4 GB | 6-20 GB | \$4.94 per month (the default) |
| 4 | 8 GB | 20-40 GB | \$10.60 per month |
| 8 | 16 GB | 40-80 GB | \$21.20 per month |
Disk above the range minimum adds \$0.09 per GB per month. Those are dedicated prices; the cost-optimized [`shared`](/docs/agents-api/billing#dedicated-and-shared-instances) type bills the shape's monthly average instead. The monthly price is the rate; the wallet is metered per minute, and only for what the instance holds: running time bills the full rate, or 4x it for an [auto-sleep](#auto-sleep) instance, and stopped or sleeping time bills the disk alone. Deleting an instance settles its final minutes and billing ends. See [Billing](/docs/agents-api/billing).
### Example
The `credit_micros` of `1000000` gives the instance \$1 of managed-spend headroom so its first chat works out of the box.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"template": "agent37-hermes",
"user": "u_882",
"budget": { "credit_micros": 1000000 }
}'
```
```python python theme={null}
import requests
resp = requests.post(
"https://api.agent37.com/v1/instances",
headers={"Authorization": "Bearer sk_live_..."},
json={
"template": "agent37-hermes",
"user": "u_882",
"budget": {"credit_micros": 1000000},
},
)
instance = resp.json()
print(instance["id"], instance["status"])
```
```javascript node theme={null}
const res = await fetch("https://api.agent37.com/v1/instances", {
method: "POST",
headers: {
Authorization: "Bearer sk_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
template: "agent37-hermes",
user: "u_882",
budget: { credit_micros: 1000000 },
}),
});
const instance = await res.json();
```
```json response theme={null}
{
"id": "ab12cd34ef",
"status": "running",
"status_reason": null,
"template": "agent37-hermes",
"template_revision": null,
"image_ref": "ghcr.io/agent37-platform/hermes:2026.07.02b",
"image_digest": "sha256:4f8d0d9e9f3b3a5a9f4488fb33274de9f7b7450c6cf6d3573e68fd231d7c8891",
"resources": { "cpu": 2, "memory": 4, "disk": 6 },
"url": "https://ab12cd34ef.agent37.app",
"domain_urls": [],
"public_ports": [],
"user": "u_882",
"name": null,
"metadata": null,
"auto_sleep": false,
"idle_timeout_seconds": 300,
"past_due": false,
"created": 1781222400
}
```
## Environment variables
`env` puts your own variables in the container. They are stored with the instance and reapplied every time the container is rebuilt, so a restart, an update, or a wake from sleep keeps them.
```bash theme={null}
curl -X POST https://api.agent37.com/v1/instances \
-H "Authorization: Bearer $AGENT37_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template": "agent37-hermes",
"env": {
"AGENT37_LLM_PROXY_URL": "https://openrouter.ai/api",
"AGENT37_MANAGED_TOKEN": "sk-or-v1-...",
"AGENT37_STARTER_MODEL_ID": "anthropic/claude-sonnet-5"
}
}'
```
Your variables layer over the [managed services](/docs/agents-api/managed-services) the platform injects, so the three names above are how you point a catalog agent at your own OpenAI-compatible provider instead of the managed one. Any endpoint works, OpenRouter included; the URL is normalized to end in `/v1`. Spend then goes to that provider, not your Agent37 wallet, and the instance's [budget](/docs/agents-api/budgets) no longer applies to model calls. For a fuller setup with your own per-user metering, see [Bring your own model](/docs/agents-api/byo-model).
Six names are set by the platform and rejected with `400 invalid_request` rather than silently ignored, because they tell the image which instance it is: `AGENT37_INSTANCE_ID`, `AGENT37_RUNTIME_IMAGE_REF`, `AGENT37_AGENT_TYPE`, `AGENT37_GATEWAY_PORT`, `OPENCLAW_GATEWAY_PORT`, and `OPENCLAW_CONTROL_UI_INSTANCE_ORIGIN`.
`env` is write-only: it is not returned by `GET /v1/instances/{id}`, and it cannot be edited after create. To change a variable, create a new instance.
## The instance object
A bare 10-character lowercase alphanumeric id, no prefix. It doubles as the DNS label in the instance's URL.
The lifecycle state. `running` means the instance's computer is up; poll `GET /v1/health` before the first message. See the [statuses table](#statuses) below.
Why the most recent lifecycle operation failed, as `{ code, message, operation, at }`, or `null` when there is no failure reason. `at` is an epoch-second timestamp.
The template the instance was built from, including its [version pin](/docs/agents-api/templates#pin-a-template-version) when it has one (`agent37-hermes@2026.07.02b`, `my-agent@2`).
The workspace template revision this instance has installed. Compare it with the template's current `revision` to detect an available update. It changes only when the instance is created or [updated](#update), and is `null` for system templates and for instances created before revisions existed (an update stamps it).
The public source reference for a system template or registry-born workspace template. `null` for an image published by a [cloud build](/docs/agents-api/templates#build-an-image-in-the-cloud). This never exposes Agent37's internal private-mirror path.
The immutable `sha256:...` digest of the image the instance runs. Use this, not a mutable `image_ref` tag, as the exact image identity. `null` only on older instances that predate digest pinning.
The shape: `cpu` (vCPUs), `memory` and `disk` (GB).
The bare instance URL, `https://{instanceId}.agent37.app`, where the agent's chat API lives (it routes to the template's `default_port`, `3737` unless declared otherwise). Every other port is reachable at `https://{instanceId}-{port}.agent37.app`, derivable with no declaration needed. Open any port in a browser with a [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls). See [Instance and preview URLs](/docs/agents-api/urls).
The instance URL mirrored under each of your workspace's active [custom domains](/docs/agents-api/domains); empty until a domain is active.
The instance's [public ports](/docs/agents-api/public-ports), each `{ port, url, domain_urls, prefix, created }`: permanent unauthenticated URLs, empty unless you created some.
Your attribution tag, echoed back.
Your label, echoed back.
Your key/value pairs, echoed back.
Whether the instance sleeps on idle. Set at create or by `PATCH`. See [Auto-sleep](#auto-sleep).
How long the instance must be idle before it sleeps, in seconds. Defaults to `300`.
Present only while `status` is `sleeping` or `waking`: when the instance fell asleep, in epoch seconds (`null` for the second or so while the checkpoint is being written). Instances that have been asleep for a long stretch may take a few seconds to start instead of waking sub-second.
`true` when the workspace balance went negative and the instance was suspended. Top up to clear it; the next request to the instance's URL wakes it. See [Billing](/docs/agents-api/billing#past-due-and-suspension).
Creation time in epoch seconds.
## Endpoints
| Method | Path | Returns |
| -------- | ---------------------------- | ---------------------------------------------------------------------- |
| `POST` | `/v1/instances` | `201` with the full instance object |
| `GET` | `/v1/instances` | `200` `{ "data": [...] }`, newest first, each the full instance object |
| `GET` | `/v1/instances/{id}` | `200` with the full instance object |
| `PATCH` | `/v1/instances/{id}` | `200` with the full instance object |
| `DELETE` | `/v1/instances/{id}` | `200` `{ "id": "...", "deleted": true }` |
| `POST` | `/v1/instances/{id}/stop` | `200` `{ id, status }` ack |
| `POST` | `/v1/instances/{id}/start` | `200` `{ id, status }` ack |
| `POST` | `/v1/instances/{id}/restart` | `200` `{ id, status }` ack |
| `POST` | `/v1/instances/{id}/update` | `200` `{ id, status, image_ref, image_digest, template_revision }` ack |
| `POST` | `/v1/instances/{id}/resize` | `200` `{ id, status, resources }` ack |
## List, get, delete
`GET /v1/instances` returns `{ "data": [ ... ] }`, newest first, each element the full instance object. `GET /v1/instances/{id}` returns one. Unknown, deleted, or other-workspace ids uniformly return `404 not_found`.
`DELETE /v1/instances/{id}` returns `{ "id": "ab12cd34ef", "deleted": true }`. It acts once: a repeat delete returns `404`. Delete settles the final metered window and billing ends; nothing is ever prepaid, so there is nothing to refund.
```bash curl theme={null}
curl -X DELETE https://api.agent37.com/v1/instances/ab12cd34ef \
-H "Authorization: Bearer sk_live_..."
# -> { "id": "ab12cd34ef", "deleted": true }
```
Delete is destructive: the instance's files, memory, and sessions are gone. To pause work while keeping everything, `stop` it instead. A stopped instance bills its disk alone; delete is what ends billing entirely.
## Edit name, metadata, and auto-sleep
`PATCH /v1/instances/{id}` edits the instance's `name`, `user` tag, and `metadata` after creation, plus its [auto-sleep](#auto-sleep) settings. These are the same fields you can set at create, and they are the only things this call changes: it never touches the running container. It returns `200` with the full instance object, the same shape as `GET`.
The patch is partial: only the keys you send change, the rest are left alone. Send a string to set a label field, or `null` (or `""`) to clear it. You must send at least one of `name`, `user`, `metadata`, `auto_sleep`, or `idle_timeout_seconds`; an empty body returns `400 invalid_request`.
A label for the instance, up to 60 characters. `null` or `""` clears it.
Your attribution tag, up to 200 characters. `null` or `""` clears it.
Your key/value pairs, up to 4 KB serialized. The object replaces the stored one, it is not merged. `null` or `{}` clears it.
Turn [auto-sleep](#auto-sleep) on or off. Takes effect within about half a minute, with no restart and no recreate.
The new idle timeout: an integer from `60` to `86400` seconds.
Edits never bill and never recreate the container, and they work in any state except `deleted`. Unknown, deleted, or other-workspace ids return `404 not_found`.
```bash curl theme={null}
curl -X PATCH https://api.agent37.com/v1/instances/ab12cd34ef \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "name": "Production agent", "user": "u_882", "metadata": { "plan": "pro" } }'
```
```python python theme={null}
import requests
resp = requests.patch(
"https://api.agent37.com/v1/instances/ab12cd34ef",
headers={"Authorization": "Bearer sk_live_..."},
json={"name": "Production agent", "user": "u_882", "metadata": {"plan": "pro"}},
)
instance = resp.json()
```
```javascript node theme={null}
const res = await fetch("https://api.agent37.com/v1/instances/ab12cd34ef", {
method: "PATCH",
headers: {
Authorization: "Bearer sk_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Production agent",
user: "u_882",
metadata: { plan: "pro" },
}),
});
const instance = await res.json();
```
```json response theme={null}
{
"id": "ab12cd34ef",
"status": "running",
"status_reason": null,
"template": "agent37-hermes",
"template_revision": null,
"image_ref": "ghcr.io/agent37-platform/hermes:2026.07.02b",
"image_digest": "sha256:4f8d0d9e9f3b3a5a9f4488fb33274de9f7b7450c6cf6d3573e68fd231d7c8891",
"resources": { "cpu": 2, "memory": 4, "disk": 6 },
"url": "https://ab12cd34ef.agent37.app",
"domain_urls": [],
"public_ports": [],
"user": "u_882",
"name": "Production agent",
"metadata": { "plan": "pro" },
"auto_sleep": false,
"idle_timeout_seconds": 300,
"past_due": false,
"created": 1781222400
}
```
## Lifecycle
Five calls control whether the instance's computer is running, which image it runs, and how big it is. Each is a `POST` to a subpath; `stop`, `start`, and `restart` take no body, `update` takes an optional `template` version, and `resize` takes the new size. They acknowledge the new state only, returning `{ id, status }` (`update` adds nullable `image_ref`, `image_digest`, and `template_revision`; `resize` adds `resources`); `GET` the instance for its full representation.
The whole disk persists, like a VM. Files anywhere on the filesystem, installed packages, edited config, connected accounts: all of it survives `stop`, `start`, `restart`, and `resize`, and rides along if the platform ever moves the instance between hosts. The one exception is `update`, which resets the operating system layer to the fresh image while keeping your data (`/home/node` and `/home/linuxbrew`). Writes outside those two directories share a 10 GB operating-system layer separate from the instance's billed disk. In-memory state is lost whenever the container is recreated; anything that must outlive a restart belongs in a file.
### Stop
`POST /v1/instances/{id}/stop` halts the container. The agent stops doing work (no cron, no heartbeats, no responses) and `status` becomes `stopped`. The data stays intact, CPU and memory are released back to the host, and the disk stays reserved on that host.
Stop also works on a `sleeping` instance, and the difference is intent: a sleeper wakes on any request, a stopped instance stays down until an explicit `start`. Stopping a sleeper discards its checkpoint (the data stays) and its URLs stop waking it. The one exception is the second or so while the sleep checkpoint is being written: stop then returns `409 try_again`, and you retry in a few seconds.
Stopping an already stopped instance returns the same ack again; any other state returns `400`.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/stop \
-H "Authorization: Bearer sk_live_..."
# -> { "id": "ab12cd34ef", "status": "stopped" }
```
### Start
`POST /v1/instances/{id}/start` brings a `stopped` instance back up, recreating the container from the image it already ran. It normally returns to its host in seconds; if that host no longer has room for the instance's CPU and memory, the platform moves the instance to one that does, and the start takes a couple of minutes while the data syncs over. Only when no host has room does it return `409 capacity_unavailable`, changing nothing. If the instance is `past_due` (suspended for non-payment), start returns `402 insufficient_balance` until the workspace is funded; topping up clears the flag on its own, and start (or any request to the instance's URLs) then boots it fresh. Starting an already running instance returns the same ack again.
Start also wakes a `sleeping` instance, with the same effect as a request to one of its URLs. For a [private sandbox with no ports](/docs/agents-api/templates#register-a-workspace-template) there is no URL to request, so start is its only wake path. During the brief window while the sleep checkpoint is being written it returns `409 try_again`; retry in a few seconds.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/start \
-H "Authorization: Bearer sk_live_..."
# -> { "id": "ab12cd34ef", "status": "running" }
```
### Restart
`POST /v1/instances/{id}/restart` recreates the container from the image already on the host (no download) and returns it to `running`. Use it to recover a wedged agent or pick up changed settings. Same image, same data. The instance must be `running`; use `start` to bring a `stopped` one back up. Concurrent restarts of the same instance do not stack: one proceeds and the rest return `409 try_again`.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/restart \
-H "Authorization: Bearer sk_live_..."
# -> { "id": "ab12cd34ef", "status": "running" }
```
### Update
`POST /v1/instances/{id}/update` pulls the template's image, resets the operating system layer to it, and preserves the data in `/home/node` and `/home/linuxbrew`. It is the one lifecycle call that discards changes outside those directories, which also makes it the clean-slate repair tool: use it to move an instance onto a newer version after a release (or after you point a workspace template at a new tag), or to recover a failed/stuck instance (read its [logs](/docs/agents-api/logs) first to see why it failed). A `running` or recoverable non-stopped instance is recreated and returns `running`; a `stopped` instance pulls the image pointer now and stays `stopped`, then uses that image the next time it starts.
The body is optional. Without one, update re-resolves the instance's stored template: a workspace instance installs the template's current image and `revision`, an unpinned system instance moves to the template's current image, and a [version-pinned](/docs/agents-api/templates#pin-a-template-version) instance stays on its pin. The one accepted field, `template`, takes any template name: the same template with an `@`, a published tag on a system template or a published revision number on a workspace template, pins that release (this works on an unpinned instance too, and pinning an earlier workspace revision is the rollback), the bare name clears the pin to follow latest, and a *different* template migrates the instance onto it. A template migration keeps the instance's id, URLs, public ports, and data, and the recreated container adopts the new template's image, default port, and agent type; the instance keeps its shape, which every template offers. Any other field returns `400`. The ack carries the resulting `status`, the applied `template` when one was passed, `image_ref`, `image_digest`, and `template_revision`. A `sleeping` instance cannot be updated: wake it first, with any request to its URL or an explicit [`start`](#start). A bad image reference on the template surfaces here as a `502 provisioning_failed`.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/update \
-H "Authorization: Bearer sk_live_..."
# -> { "id": "ab12cd34ef", "status": "running", "image_ref": "ghcr.io/acme/my-agent:v2", "image_digest": "sha256:9b2e...c41f", "template_revision": 2 }
# Set or move a version pin (or pass the bare name to clear it): a system tag,
# or a workspace revision like '{ "template": "my-agent@1" }' to roll back:
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/update \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "template": "agent37-hermes@" }'
# Migrate the instance onto a different template (data kept, container recreated from the new image):
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/update \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "template": "my-custom-agent" }'
# -> { "id": "ab12cd34ef", "status": "running", "template": "my-custom-agent", "image_ref": "ghcr.io/acme/my-agent:v2", "image_digest": "sha256:9b2e...c41f", "template_revision": 1 }
```
### Resize
`POST /v1/instances/{id}/resize` grows a running instance to a bigger size. The body uses the same vocabulary as create's `resources`, and omitted fields keep their current value, so `{ "disk": 15 }` grows disk alone and `{ "cpu": 4, "memory": 8 }` moves up a shape (disk rises to the new shape's minimum if it was below it). Resize only grows: any request that would shrink a dimension returns `400`, and moving to a smaller size means creating a new instance. The ack carries the new `resources`, and the meter bills at the new rate from the moment of the resize (see [Billing](/docs/agents-api/billing)).
The container is recreated with the new limits, like `restart`: the disk, instance id, and URLs are kept, in-memory state is lost, and the instance is back in seconds. If its current host cannot fit the increase, the platform moves the instance to one that can; the resize then takes a couple of minutes while the data syncs over. Only when no host has room does it return `409 capacity_unavailable`, changing nothing.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/resize \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "cpu": 4, "memory": 8 }'
# -> { "id": "ab12cd34ef", "status": "running", "resources": { "cpu": 4, "memory": 8, "disk": 20 } }
```
## Auto-sleep
An instance with `auto_sleep: true` does not have to be up to be available. Once no bytes have moved through any of its URLs for `idle_timeout_seconds` (default 300, from one minute to a day), the platform checkpoints it and `status` becomes `sleeping`: CPU and memory are released, the disk stays reserved, and billing drops to the disk rate alone (see [Billing](/docs/agents-api/billing)). Activity is data flow in either direction on any of the instance's URLs; a connection that is open but silent does not count, and API reads like `GET /v1/instances/{id}` never reset the timer.
The wake guarantee is priced in: an auto-sleep instance bills its awake minutes at 4x the compute rate, because the platform keeps capacity for it wakeable on demand instead of sharing it. A mostly-idle instance still comes out well below the always-on price: a 2 vCPU / 4 GB instance awake an hour a day costs about \$1.34 per month, versus \$4.94 always-on.
Waking is transparent. Any request to any of the instance's URLs wakes it: the request is held while the instance restores, then forwarded, usually well under a second. So does an explicit [`POST /v1/instances/{id}/start`](#start); for a private sandbox with no ports, which receives no requests, that is the only wake path. If the checkpoint cannot be restored, or the instance was moved to cold storage after a long idle stretch, the wake falls back to a fresh boot and takes a few seconds instead. In-memory state usually survives a wake, but it is not guaranteed: treat anything that must outlive a sleep as a file.
While asleep:
* The instance object reports `status: "sleeping"` and `slept_at`, for example `"slept_at": 1781222400`.
* [Signed URLs](/docs/agents-api/urls#browser-access-with-signed-urls) can still be minted, and opening one is exactly the kind of request that wakes the instance.
* `stop` works, and means "stay down until I `start`" (see [Stop](#stop)). During the brief window while the checkpoint is being written it returns `409 try_again`; retry in a few seconds.
* `restart`, `update`, and `resize` require `running` and return `400`: wake the instance first, with any request to its URL or an explicit [`start`](#start).
Both fields are set at create and editable any time with `PATCH /v1/instances/{id}`, no restart needed. Instances default to `auto_sleep: false` and never sleep unless you opt them in.
## Statuses
| Status | Meaning |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `provisioning` | Being created. You only observe this if a create is in flight. |
| `running` | Up. Poll `GET /v1/health` before the first message. |
| `stopping` | A stop is in progress. |
| `stopped` | Halted until an explicit `start`. Data intact, disk reserved, compute released. Bills disk only. |
| `starting` | A start is in progress. |
| `restarting` | A restart is in progress. |
| `updating` | An update or resize is in progress. |
| `sleeping` | Checkpointed on idle ([auto-sleep](#auto-sleep)). Any request to its URLs, or an explicit `start`, wakes it. Bills disk only. |
| `waking` | A wake is in progress on the slower fallback path; the held request completes when it finishes. |
| `failed` | A create or lifecycle action failed. |
| `deleting` | A delete is in progress. |
| `deleted` | Gone. The id reads as `404` from here on. |
`past_due` is a flag, not a status: a suspended instance shows it alongside `sleeping` (or `stopped`, if it already was). Top up the wallet to clear it; see [Billing](/docs/agents-api/billing#past-due-and-suspension).
## Capacity and limit errors
| Error | When |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `402 insufficient_balance` | Create when the wallet holds less than one day of the instance's running rate, or start of a past-due instance while the balance is negative. |
| `409 instance_limit_reached` | Create when the workspace is at its instance cap: one instance on the free credit, 10 once 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). |
| `403 tier_limit` | Create or resize asks for a shape larger than your plan includes. Free workspaces run any template on the 2 vCPU / 4 GB shape; a top-up unlocks the 4/8 and 8/16 shapes. |
| `403 shared_unavailable` | Create asks for `"type": "shared"` on a workspace it isn't enabled for. Nothing is provisioned; book the call in the message. |
| `503 no_capacity` | Create when no host has capacity for the requested shape right now. |
| `409 capacity_unavailable` | Start or resize when no host in the fleet can fit the instance right now; the platform first tries to move it to a host with room. |
See [Errors](/docs/agents-api/errors) for the full catalog and the error envelope.
# App integrations
Source: https://www.agent37.com/docs/agents-api/integrations
Connect Gmail, Slack, Notion, and 250+ other apps to an instance over the Hosting API: managed Composio, one entity per instance, OAuth handled for you.
Every instance ships with managed [Composio](https://composio.dev) credentials, so the agent can call real apps (Gmail, Slack, Notion, GitHub, Google Calendar, and hundreds more) without you wiring up OAuth or holding any provider tokens. The agent can connect apps in conversation, but you can also drive the whole flow over the Hosting API: browse the catalog, start a connection, list what's connected, and disconnect.
These endpoints live under each instance and operate on that instance's own Composio entity.
Prefer to run integrations on **your own** Composio project, with your API key, your users' connected accounts, and your own metering? That's a different path: see [Use your own Composio](/docs/agents-api/composio).
Browsing, connecting, listing, and disconnecting are **free**: they never debit the wallet or the budget. Only the agent's actual tool calls at runtime are metered, as managed Composio spend. See [Billing](#billing) below for the rate.
## The entity model
Each instance maps to exactly one Composio entity, derived from your workspace and the instance id. You never construct or pass it; every endpoint here resolves it from the path instance. The practical consequences:
* **Per-instance isolation.** Create one instance per end user (as in [Instances](/docs/agents-api/instances)) and their connected accounts never leak across users.
* **Multiple accounts of the same app.** Connect the same toolkit more than once to attach, say, two Gmail accounts to one instance. Each call returns a distinct `connectedAccountId`; the agent chooses between them per tool call by `connected_account_id`.
* **Survives restarts and rolls.** Connections belong to the entity, not the running container, so they persist across stop/start and image updates.
## Endpoints
| Method | Path | Returns |
| -------- | ------------------------------------------------------------------ | ------------------------------ |
| `GET` | `/v1/instances/{id}/integrations/toolkits` | `200` a page of available apps |
| `POST` | `/v1/instances/{id}/integrations/connect` | `200` an authorization link |
| `GET` | `/v1/instances/{id}/integrations/connections` | `200` connected accounts |
| `DELETE` | `/v1/instances/{id}/integrations/connections/{connectedAccountId}` | `200` deletion confirmation |
All four require the `sk_live_` key, and the path instance must belong to your workspace; otherwise the call returns `404`.
## Browse the app catalog
`GET /v1/instances/{id}/integrations/toolkits` lists the apps you can connect, newest-relevant first, paginated by cursor.
Filter the catalog by name or slug. Must be at least 3 characters; a shorter value returns `400`.
Page size, clamped to `1`–`24`.
The `nextCursor` from a previous page. Omit for the first page.
The page of toolkits. Each carries `slug`, `name`, `description`, `logo`, `enabled`, `isNoAuth`, and `authSchemes`.
Pass back as `cursor` to fetch the next page. `null` on the last page.
Total matches for the query.
```bash curl theme={null}
curl "https://api.agent37.com/v1/instances/ab12cd34ef/integrations/toolkits?search=gmail&limit=5" \
-H "Authorization: Bearer sk_live_..."
```
```python python theme={null}
import requests
H = {"Authorization": "Bearer sk_live_..."}
page = requests.get(
"https://api.agent37.com/v1/instances/ab12cd34ef/integrations/toolkits",
headers=H,
params={"search": "gmail", "limit": 5},
).json()
```
```javascript node theme={null}
const H = { Authorization: "Bearer sk_live_..." };
const page = await (await fetch(
"https://api.agent37.com/v1/instances/ab12cd34ef/integrations/toolkits?search=gmail&limit=5",
{ headers: H },
)).json();
```
```json response theme={null}
{
"items": [
{
"slug": "gmail",
"name": "Gmail",
"description": "Send, read, and search email.",
"logo": "https://logos.composio.dev/api/gmail",
"enabled": true,
"isNoAuth": false,
"authSchemes": ["OAUTH2"]
}
],
"nextCursor": null,
"totalItems": 1
}
```
## Connect an app
`POST /v1/instances/{id}/integrations/connect` starts an OAuth connection for one toolkit and returns an authorization link. Open `redirectUrl` in a browser, grant access, and the connection becomes active for this instance's entity.
The toolkit slug to connect, for example `gmail` (from the catalog's `slug`).
Where to send the user after they grant access. If present it **must** be an absolute `https://` URL (anything else returns `400`). Omit it to use Composio's hosted "you can close this window" page, with no callback needed.
The workspace's own Composio auth configs are applied automatically, so toolkits you've set up with custom OAuth credentials use them transparently.
The toolkit slug, echoed back.
The id of the pending connected account. Becomes active once the user completes the link, and identifies this account in [connections](#list-connections) and at tool-call time.
The authorization URL to open in a browser.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connect \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "toolkit": "gmail" }'
```
```python python theme={null}
conn = requests.post(
"https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connect",
headers={**H, "Content-Type": "application/json"},
json={"toolkit": "gmail"},
).json()
# Send the user to conn["redirectUrl"].
```
```javascript node theme={null}
const conn = await (await fetch(
"https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connect",
{
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify({ toolkit: "gmail" }),
},
)).json();
```
```json response theme={null}
{
"toolkit": "gmail",
"connectedAccountId": "ca_7f3a9b21",
"redirectUrl": "https://backend.composio.dev/api/v3/.../authorize"
}
```
Pass a `callbackUrl` to return the user to your own app after they grant access:
```json theme={null}
{ "toolkit": "gmail", "callbackUrl": "https://app.example.com/integrations/done" }
```
### Returning the user to your app
`callbackUrl` is where Composio sends the user once they grant access. Point it at a page in your own app to keep the flow seamless instead of leaving them on Composio's hosted page. Two things make the round-trip clean:
* **Carry your own context.** Add query params to the `callbackUrl` you pass, so the page they land on knows what just happened, for example `https://app.example.com/integrations/done?toolkit=gmail`. The user returns to your URL with those params intact; Composio doesn't add any of its own, so put everything you need to know there yourself.
* **Confirm it took.** Landing back on your page means the user finished the OAuth screens, not that the account is live. Verify by calling [`GET /v1/instances/{id}/integrations/connections`](#list-connections): match the `connectedAccountId` you got from `connect` and check its `status` is `ACTIVE`. A new connection can take a moment to settle, so poll briefly if it isn't active on the first read.
### Multiple accounts of one app
Call `connect` again for the same toolkit to add a second account, say a second Gmail inbox. You get a fresh `connectedAccountId`, and both stay attached to the instance. The agent picks which to use per tool call via `connected_account_id`, so "send from my work address" and "send from my personal address" both work on one instance.
### When a toolkit needs your own credentials
Some toolkits have no managed OAuth app and require your own credentials. For those, `connect` returns `422`:
```json theme={null}
{ "error": "custom_auth_required", "toolkit": "salesforce" }
```
Configure the toolkit's auth config for your workspace, then retry. If managed Composio isn't configured at all (or is temporarily unavailable), the call returns `503`.
## List connections
`GET /v1/instances/{id}/integrations/connections` returns the accounts connected to this instance. Pass `toolkit` to filter to one app.
Return only connections for this toolkit slug.
Composio connected-account objects, passed through as-is. Each carries `id`, `toolkitSlug`, `toolkitName`, `status`, and the account's auth and timestamp metadata. Because this is Composio's native shape, its fields follow Composio's naming and its timestamps are epoch **milliseconds** (not the Hosting API's usual seconds).
```bash curl theme={null}
curl "https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connections?toolkit=gmail" \
-H "Authorization: Bearer sk_live_..."
```
```python python theme={null}
conns = requests.get(
"https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connections",
headers=H,
params={"toolkit": "gmail"},
).json()
```
```javascript node theme={null}
const conns = await (await fetch(
"https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connections?toolkit=gmail",
{ headers: H },
)).json();
```
```json response theme={null}
{
"connections": [
{
"id": "ca_7f3a9b21",
"toolkitSlug": "gmail",
"toolkitName": "Gmail",
"status": "ACTIVE",
"authConfigId": "ac_managed_gmail",
"authScheme": "OAUTH2",
"isDisabled": false,
"createdAt": 1781222400000,
"updatedAt": 1781222480000
}
]
}
```
## Disconnect an app
`DELETE /v1/instances/{id}/integrations/connections/{connectedAccountId}` removes one connected account. The account must belong to this instance's entity, or the call returns `404`. After deletion the agent can no longer use that account; other accounts on the instance are untouched.
```bash curl theme={null}
curl -X DELETE \
https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connections/ca_7f3a9b21 \
-H "Authorization: Bearer sk_live_..."
```
```python python theme={null}
res = requests.delete(
"https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connections/ca_7f3a9b21",
headers=H,
).json()
```
```javascript node theme={null}
const res = await (await fetch(
"https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connections/ca_7f3a9b21",
{ method: "DELETE", headers: H },
)).json();
```
```json response theme={null}
{ "id": "ca_7f3a9b21", "deleted": true }
```
## Billing
Managing integrations is free: nothing on this page debits the budget or the wallet. Only the agent actually calling a connected app at runtime is metered, at \$0.000114 per call (114 micros), drawn from the instance [budget](/docs/agents-api/budgets) and the workspace wallet like any other managed service.
That spend shows up in `GET /v1/instances/{id}/usage` under `by_integration.composio`. See [Managed services & budgets](/docs/agents-api/budgets) for the rate, the usage shape, and the `402` refusal that keeps the instance running when the budget or wallet can't cover a tool call.
# Read logs
Source: https://www.agent37.com/docs/agents-api/logs
Fetch an instance's boot and runtime logs from your backend to debug a container that will not start or crashed after booting.
`GET /v1/instances/{id}/logs` returns a snapshot of the container's own output, everything the entrypoint and the agent printed to stdout and stderr, plus a compact health readout. It is how you debug an instance that crashed after booting, or your own [custom image](/docs/agents-api/custom-image) that will not come up.
Unlike [exec](/docs/agents-api/exec), which needs a `running` container, logs works in **any** state: `running`, `sleeping`, `stopped`, or `failed`. That is the point. When a container will not stay up, exec has nothing to attach to, but its logs are still there. The one gap is an instance that slept so long it was parked off its host: its logs read empty until a wake brings it back.
A container that never got created at all (a bad image reference, or an `exec format error` from a wrong-architecture image) has no logs to show: `logs` is empty and `health` is `null`. The reason for that kind of failure lands in `status_reason` on the instance object, so fetch `GET /v1/instances/{id}` and read it. Logs cover the case where a container **did** start and then misbehaved.
## Request
How many of the most recent log lines to return. Defaults to 500, capped at 2000 (a larger value is clamped down). A value that is not a positive integer returns `400 invalid_request`.
## Response
The container's combined stdout and stderr, most recent `tail` lines. Capped at 512 KB; see `truncated`. Empty when there is no container.
`true` when the output spilled past the 512 KB cap and the oldest lines were dropped.
A compact runtime readout, or `null` when there is no container. It answers "did it crash, and why."
Whether the container is currently up.
How many times the container has booted beyond its first start. Wakes from [auto-sleep](/docs/agents-api/instances#auto-sleep) count too, so a rising number on a sleeper is normal; a count that climbs while the instance never sleeps is a crash loop.
The last exit code when the runtime still has one, `null` otherwise. Expect `null` in most cases: crash evidence lives in `logs`, `restart_count`, and `resource_verdict`.
Best-effort inference that the container was killed for exceeding its memory limit. `resource_verdict.memory` at `"critical"` is the reliable version of the same signal. If either fires, [resize](/docs/agents-api/instances#resize) to a larger shape.
Per-dimension pressure (`memory`, `cpu`, `disk`, and `overall`, each `healthy`, `pressure`, or `critical`), or `null` when the container is not running. `memory` reads `"critical"` after the instance has been killed for exceeding its memory limit.
When the snapshot was taken, in epoch seconds.
Unknown, deleted, or other-workspace ids return `404 not_found`. If the platform cannot reach the instance's host, you get `502 provisioning_failed`.
## Example
```bash curl theme={null}
curl "https://api.agent37.com/v1/instances/ab12cd34ef/logs?tail=200" \
-H "Authorization: Bearer sk_live_..."
```
```python python theme={null}
import requests
resp = requests.get(
"https://api.agent37.com/v1/instances/ab12cd34ef/logs",
headers={"Authorization": "Bearer sk_live_..."},
params={"tail": 200},
)
result = resp.json()
print(result["health"], result["logs"])
```
```javascript node theme={null}
const resp = await fetch(
"https://api.agent37.com/v1/instances/ab12cd34ef/logs?tail=200",
{ headers: { Authorization: "Bearer sk_live_..." } }
);
const result = await resp.json();
console.log(result.health, result.logs);
```
```json response theme={null}
{
"logs": "[agent37-hermes] Booting (gateway_port=3737 ...)\n[agent37-hermes] Starting hermes gateway daemon...\n",
"truncated": false,
"health": {
"running": true,
"restart_count": 0,
"exit_code": null,
"oom_suspected": false,
"resource_verdict": { "memory": "healthy", "cpu": "healthy", "disk": "healthy", "overall": "healthy" }
},
"fetched_at": 1781222420
}
```
## Debugging a failed instance
When an instance is `failed` or stuck, logs and `health` together tell you which layer broke:
* **The image will not start** (a climbing `restart_count`, with the same error at the top of `logs` on every boot): read the entrypoint's own error. This is the usual signal for a [custom image](/docs/agents-api/custom-image) mistake, such as a missing binary, an entrypoint that exits, or a service that never listens on `default_port`.
* **It ran out of memory** (`resource_verdict.memory` is `"critical"`): the agent needs a bigger box. [Resize](/docs/agents-api/instances#resize) to a larger shape.
* **It booted but the agent is wedged**: the logs show the last thing it did before it stopped responding. [Restart](/docs/agents-api/instances#restart) to recover it.
Once you have a `running` container, drop into it with [exec](/docs/agents-api/exec) to inspect further.
# Managed services in your image
Source: https://www.agent37.com/docs/agents-api/managed-services
Every instance gets a working LLM endpoint and a Composio MCP server from its environment. No API keys, no OAuth wiring, and it works in a fully custom image.
Every Agent37 instance boots with credentials already in its environment. Your image does not
receive them from you, does not store them, and does not need to ask for them:
| Variable | Value |
| -------------------------- | -------------------------------------- |
| `AGENT37_MANAGED_TOKEN` | A per-instance token |
| `AGENT37_LLM_PROXY_URL` | `https://api.agent37.com/llm/v1` |
| `AGENT37_COMPOSIO_MCP_URL` | `https://api.agent37.com/mcp/composio` |
| `AGENT37_INSTANCE_ID` | This instance's id |
That token opens both endpoints. **This applies to any image**, including one you built
yourself from scratch: the platform injects these at container create, not the image.
Those four are the contract. A container's environment carries other `AGENT37_`-prefixed
variables that are runtime internals; they change without notice, so build against the four
above and nothing else.
## A model
An OpenAI-compatible endpoint with exactly two routes: `GET /v1/models` and
`POST /v1/chat/completions`. Point any OpenAI-compatible client at it.
```bash theme={null}
curl -sS "$AGENT37_LLM_PROXY_URL/chat/completions" \
-H "Authorization: Bearer $AGENT37_MANAGED_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "model": "default", "messages": [{"role":"user","content":"hello"}] }'
```
`"default"` is a fast, low-cost model we pick and keep current. `GET /v1/models` lists every
paid model you can name instead; it is free to call. Streaming works. Free models and models
without tool support are rejected, because agents need tools.
## Integrations
A [Composio](https://composio.dev) MCP server, scoped to this instance's own entity, at
`$AGENT37_COMPOSIO_MCP_URL` with the same bearer token. It speaks streamable HTTP.
```bash theme={null}
curl -sS "$AGENT37_COMPOSIO_MCP_URL" \
-H "Authorization: Bearer $AGENT37_MANAGED_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
It exposes a handful of meta-tools rather than hundreds of individual ones, so it costs very
little context: the agent searches the catalog for what it needs, then executes it. The tool
names and their behavior are Composio's surface, documented in
[Composio's meta-tools reference](https://docs.composio.dev/reference/meta-tools/search_tools).
Billing is ours and is simple: discovery (searching tools, fetching schemas) is free; executing
tools and managing connections bills per tool call, with a batch execute capped at 50 calls.
The connection-management meta-tool lets the agent start its own OAuth connections from inside
the instance. Driving [the integrations API](/docs/agents-api/integrations) from your backend is the
alternative, not the prerequisite.
`POST` and `DELETE` only. A `GET` returns `405` by design, because proxying the optional
server-push stream would hold one open connection per instance. Well-behaved MCP clients treat
`405` as "this server has no server-push stream" and carry on, but some probe the older SSE
transport and report the server as failed. If your client offers an explicit transport
setting, set it to streamable HTTP.
## Wiring an agent to both
Two config blocks, using [pi](https://pi.dev) as the example. Neither contains a credential;
both reference the environment variable instead:
```json models.json theme={null}
{
"providers": {
"agent37": {
"baseUrl": "https://api.agent37.com/llm/v1",
"apiKey": "$AGENT37_MANAGED_TOKEN",
"api": "openai-completions",
"models": [{ "id": "default", "name": "Agent37 Managed", "contextWindow": 128000, "maxTokens": 8192 }]
}
}
}
```
```json mcp.json theme={null}
{
"mcpServers": {
"composio": {
"type": "streamable-http",
"url": "https://api.agent37.com/mcp/composio",
"headers": { "Authorization": "Bearer ${AGENT37_MANAGED_TOKEN}" }
}
}
}
```
A complete, runnable image doing exactly this lives at
[agent37-platform/pi-agent-image](https://github.com/agent37-platform/pi-agent-image).
**Reference the variable, never copy its value.** The token is reissued, and the previous one
revoked immediately, on create, start, restart, update, resize, migrate, and recovery. A config
written once with the literal token baked in keeps working until the first restart and then
fails with `401` forever, because your config file is on the persistent volume and survives the
rotation. If your agent cannot read env vars from its config, have the entrypoint rewrite that
file from `$AGENT37_MANAGED_TOKEN` on **every** boot, unconditionally. Write-if-absent is the
bug.
## Budgets
Both endpoints draw on the instance [budget](/docs/agents-api/budgets) and then the workspace wallet.
LLM calls are metered at provider cost with no markup; Composio tool calls at \$0.000114 each.
Refusals are a `402`:
```json theme={null}
{ "error": { "type": "instance_budget_exhausted", "message": "..." } }
```
`insufficient_balance` is the other `type`, meaning the workspace wallet is empty rather than
the instance cap being reached.
**An instance created without a budget has a cap of zero, and every managed call returns `402`.**
Pass one at create, or `PATCH /v1/instances/{id}/budget` with `monthly_cap_micros`. Agents often
surface this badly: a `402` from the model endpoint can look like a hang rather than an error.
If a new instance's agent does nothing, read `GET /v1/instances/{id}/budget` first.
## Filesystem rules for a custom image
`/home/node` and `/home/linuxbrew` are persistent volumes bind-mounted over your image at
runtime. The volume is created **empty**, and nothing is ever copied out of the image into it, so
anything your image writes under those paths is unreachable from the running container.
This catches people twice, because npm's default global prefix is under `/home/node`:
```dockerfile theme={null}
RUN npm install -g my-agent # vanishes at boot
RUN NPM_CONFIG_PREFIX=/usr/local npm install -g my-agent # survives
```
Put binaries in `/usr/local`, and put config your agent reads at startup somewhere outside
`/home` too, or have the entrypoint write it into the volume on every boot. Files your agent
creates at runtime under `/home/node` persist normally across restarts and image updates.
## Not using them
Nothing obliges your image to touch either endpoint. To run on your own model, ignore the proxy
variables and point your agent at your own provider, as in
[Build a custom image](/docs/agents-api/custom-image#bring-your-own-model-instead). To skip
integrations, leave the MCP server out of your agent's config. Unused endpoints are never
billed, because billing is per call.
# Read metrics
Source: https://www.agent37.com/docs/agents-api/metrics
Fetch an instance's CPU, memory, and disk history to see whether it is sized right, spot leaks, and know before it hits a limit.
`GET /v1/instances/{id}/metrics` returns up to 14 days of resource history: CPU cores in use, memory bytes, and disk bytes, sampled about once a minute on the host, alongside the instance's configured limits. Where [logs](/docs/agents-api/logs) answer "what did it print", metrics answer "is it running out of something": a memory line creeping toward its limit, CPU pinned at the cap, or a disk filling up.
Samples start landing a couple of minutes after an instance first runs, so a just-created instance answers `200` with empty series at first. While an instance is `sleeping` or `stopped` there is no live container to measure: CPU and memory show gaps for those periods, and only disk keeps reporting.
## Request
The window to return, ending now. Defaults to 24, capped at 336 (14 days, which is also the retention). A value that is not an integer in range returns `400 invalid_request`.
## Response
Three time series, each an array of `[unixSeconds, value]` pairs in ascending time order.
CPU in use, in cores (for example `0.4` on a 2-vCPU instance means 20% of one core). Compare against `limits.cpu_cores`.
Memory in use, in bytes.
Disk used by the instance's data volume, in bytes.
The instance's configured shape in the same units: `cpu_cores`, `memory_bytes`, `disk_bytes`. Chart these as ceilings over the series.
The window that was returned.
Spacing between points. It widens with the window (60s at one hour, coarser at 14 days) so any range returns a chart-sized payload of a few hundred points.
When the query ran, in epoch seconds.
Unknown, deleted, or other-workspace ids return `404 not_found`. If the metrics store is unreachable, you get `503 try_again`; retry shortly.
## Example
```bash curl theme={null}
curl "https://api.agent37.com/v1/instances/ab12cd34ef/metrics?hours=24" \
-H "Authorization: Bearer sk_live_..."
```
```python python theme={null}
import requests
resp = requests.get(
"https://api.agent37.com/v1/instances/ab12cd34ef/metrics",
headers={"Authorization": "Bearer sk_live_..."},
params={"hours": 24},
)
result = resp.json()
latest = result["series"]["memory_bytes"][-1]
print(latest, result["limits"]["memory_bytes"])
```
```javascript node theme={null}
const resp = await fetch(
"https://api.agent37.com/v1/instances/ab12cd34ef/metrics?hours=24",
{ headers: { Authorization: "Bearer sk_live_..." } }
);
const result = await resp.json();
console.log(result.series.cpu_cores.at(-1), result.limits.cpu_cores);
```
```json response theme={null}
{
"series": {
"cpu_cores": [[1783290000, 0.12], [1783290360, 0.31]],
"memory_bytes": [[1783290000, 812646400], [1783290360, 1265789952]],
"disk_bytes": [[1783290000, 2147483648], [1783290360, 2147745792]]
},
"limits": { "cpu_cores": 2, "memory_bytes": 4294967296, "disk_bytes": 6442450944 },
"hours": 24,
"step_seconds": 360,
"fetched_at": 1783293600
}
```
## Reading the numbers
* **Memory near its limit**: an agent that keeps climbing toward `limits.memory_bytes` will eventually be killed and restarted (that shows up in [logs](/docs/agents-api/logs) as a rising `restart_count` and a `"critical"` memory verdict). [Resize](/docs/agents-api/instances#resize) before it gets there.
* **CPU pinned at the limit**: sustained `cpu_cores` at `limits.cpu_cores` means the agent is throttled and everything it does is slower. Resize, or give it less concurrent work.
* **Disk trending up**: disk is the one series that never resets on restart. A steady climb is usually accumulated artifacts or caches; clean up over [exec](/docs/agents-api/exec), or resize for more disk.
The same charts live in the dashboard on the instance's **Metrics** tab.
# Models
Source: https://www.agent37.com/docs/agents-api/models
List the models a harness can run, and set the model and provider per turn.
The model is the LLM a turn runs on; the agent harness is the software driving it. This page lives on the instance URL, not the hosting API: the base is `https://{instanceId}.agent37.app`, with the same `sk_live_` key sent as the `X-Agent37-Key` header. See [Instance and preview URLs](/docs/agents-api/urls).
## List models
`GET /v1/models` lists the models a harness can run, in the OpenAI list shape, `{ "object": "list", "data": [...] }`, so any OpenAI-compatible client works against it. It reports on one harness: the instance default, or the one named by `?agent=`, `hermes` or `openclaw`, and the response echoes which `agent` answered. An unknown `?agent=` value is `400 validation_error`, omitting it (or sending it empty) targets the instance default, and targeting a harness the instance was not provisioned with is `503 agent_unavailable`. The result is cached for about 60 seconds, so a newly available model can take up to a minute to appear.
Always `"list"`.
Which harness this list is for, `hermes` or `openclaw`.
The model used when a turn does not name one.
The provider of the default model.
One entry per model. Each is an OpenAI-compatible model object plus a few additive fields a UI can group and label on:
* `id`: the model id. Pass it as `model` on a turn.
* `object`: always `"model"`.
* `created`: Unix seconds. We don't track per-model creation time, so this is a stable placeholder (`0`).
* `owned_by`: the upstream provider, e.g. `nous` or `anthropic`.
* `label`: a human-readable name.
* `source`: where the entry comes from, one of `current`, `catalog`, `custom`, or `alias`.
* `is_default`: `true` for the default model.
```bash curl theme={null}
curl https://ab12cd34ef.agent37.app/v1/models \
-H "X-Agent37-Key: sk_live_..."
```
```python python theme={null}
import requests
models = requests.get(
"https://ab12cd34ef.agent37.app/v1/models",
headers={"X-Agent37-Key": "sk_live_..."},
).json()
```
```javascript node theme={null}
const models = await (await fetch(
"https://ab12cd34ef.agent37.app/v1/models",
{ headers: { "X-Agent37-Key": "sk_live_..." } },
)).json();
```
```json response theme={null}
{
"object": "list",
"agent": "hermes",
"default_model": "claude-sonnet-4-5",
"default_provider": "anthropic",
"data": [
{
"id": "claude-sonnet-4-5",
"object": "model",
"created": 0,
"owned_by": "anthropic",
"label": "Claude Sonnet 4.5",
"source": "catalog",
"is_default": true
},
{
"id": "gpt-5.2",
"object": "model",
"created": 0,
"owned_by": "openai",
"label": "GPT-5.2",
"source": "catalog",
"is_default": false
}
]
}
```
## Choosing a model per turn
`model` and `provider` are dials you set per turn on [`POST /v1/responses`](/docs/agents-api/chat): omit them to keep the session's current model, or send them to switch. A continuation that sets them updates the session's model for the turns that follow.
# Public ports
Source: https://www.agent37.com/docs/agents-api/public-ports
Give one port of an instance a permanent, unauthenticated HTTPS URL for webhooks, embeds, and anything else that can't send an API key.
Every instance URL normally demands a credential: an `X-Agent37-Key` header for API calls, a [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls) for a browser. A **public port** is the third option: an HTTPS URL for one port of one instance that anyone can reach with no credential at all, and that keeps working until you delete it.
The canonical use is webhooks. An external service (a telephony provider, a payment processor, a Git host) needs a permanent URL it can POST to, and it will never attach your `sk_live_` key. Signed URLs expire, so they are wrong for a webhook registration; a public port is exactly right.
Anyone who has the URL reaches the port. Put your app's own authentication in front of anything sensitive. Hermes, for example, rejects webhook deliveries without a valid subscription signature: the public URL supplies reachability, not trust.
## Create one
Declare public ports when you [create the instance](/docs/agents-api/instances#create-an-instance), or add one to a running instance:
```bash at create theme={null}
curl -X POST https://api.agent37.com/v1/instances \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "template": "agent37-hermes", "public_ports": [{ "port": 8644 }] }'
```
```bash on a running instance theme={null}
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/public-ports \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "port": 8644 }'
```
```python python theme={null}
import requests
r = requests.post(
"https://api.agent37.com/v1/instances/ab12cd34ef/public-ports",
headers={"Authorization": "Bearer sk_live_..."},
json={"port": 8644},
)
print(r.json()["url"])
```
```javascript node theme={null}
const res = await fetch("https://api.agent37.com/v1/instances/ab12cd34ef/public-ports", {
method: "POST",
headers: { Authorization: "Bearer sk_live_...", "Content-Type": "application/json" },
body: JSON.stringify({ port: 8644 }),
});
console.log((await res.json()).url);
```
```json response theme={null}
{
"port": 8644,
"url": "https://a1b2c3d4e5f6a7b8c9d0.agent37.app",
"domain_urls": [],
"prefix": null,
"label": null,
"created": 1751928000
}
```
The container port the URL routes to.
The public HTTPS URL. With no `prefix`, the hostname is a server-minted 20-character random slug: unguessable, so the URL itself is the only credential. Treat it like one: it leaks the way any URL leaks (Referer headers, browser history, logs).
The same URL mirrored under each of your workspace's active [custom domains](/docs/agents-api/domains); empty until a domain is active.
Echoes the prefix you chose, or `null` for a slug URL.
A display name for the link, set with an optional `label` on create (up to 64 characters). Purely cosmetic: it appears in listings and the dashboard, and never affects the URL.
Unix seconds when the entry was created.
The entries also ride on every instance read as `public_ports`, and `GET /v1/instances/{id}/public-ports` lists them on their own.
You can serve these on your own domain too: register a [custom domain](/docs/agents-api/domains) and every public-port hostname is mirrored, still credential-free, at `{hostname}.yourdomain.com`. Once a domain is active, each entry's `domain_urls` field lists the mirrored URLs.
## Named URLs with `prefix`
Pass a `prefix` to get a deterministic hostname instead of a random slug:
```json request theme={null}
{ "port": 3000, "prefix": "crm" }
```
```json response theme={null}
{
"port": 3000,
"url": "https://crm-ab12cd34ef.agent37.app",
"domain_urls": [],
"prefix": "crm",
"label": null,
"created": 1751928000
}
```
The hostname is `{prefix}-{instanceId}`: readable, stable across delete and re-create, and **guessable by design**. Anyone who knows your instance id can derive it, so a prefix URL is for things that are meant to be found. The instance-id suffix is the namespace, so prefixes never collide across instances and nobody can squat a bare name. A prefix is 1–30 lowercase letters, digits, or hyphens, with no leading or trailing hyphen.
## Delete (revoke)
```bash curl theme={null}
curl -X DELETE https://api.agent37.com/v1/instances/ab12cd34ef/public-ports/8644 \
-H "Authorization: Bearer sk_live_..."
```
```json response theme={null}
{ "port": 8644, "deleted": true }
```
Revocation propagates to the edge in seconds. The delete acts once; repeating it returns `404`. To **rotate** a leaked slug URL, delete the entry and re-create it; the new entry gets a fresh slug. (A prefix entry re-created with the same prefix comes back at the same hostname; that stability is the point of a prefix.)
## Recipe: Hermes webhooks
Hermes listens on port `8644` and authenticates deliveries with a per-subscription secret. Create a named public port such as `{ "port": 8644, "prefix": "webhooks" }`, then replace the `http://localhost:8644` origin that Hermes shows with the returned Agent37 `url`. Keep Hermes's `/webhooks/` path.
See [Hermes webhooks](/docs/agents-api/hermes-webhooks) for the short Agent37 setup.
## Rules and limits
* **One URL per port**, at most 20 public ports per instance. Creating a second entry for the same port returns `409 public_port_exists` (delete first to rotate).
* **Platform ports can't be made public.** `3737` (the gateway; a public gateway would be a keyless agent API), `9119`, `7681` (terminal), `8080` (file browser), `6080`, `7890`, and `22022` ([SSH](/docs/agents-api/ssh)) are rejected with `400`. They stay reachable with `X-Agent37-Key` or a signed URL, except `22022`, which takes an authenticated SSH tunnel and nothing else.
* Public traffic is ordinary instance traffic: no separate meter, billed to the workspace like any other request.
* A request to a public URL [wakes a sleeping instance](/docs/agents-api/urls#sleeping-instances-wake-on-request), which for a webhook endpoint is the feature working. The flip side: any traffic to the URL keeps an [auto-sleep](/docs/agents-api/instances#auto-sleep) instance awake, and auto-sleep instances bill at the 4x awake rate, so a scraped or polled public URL keeps the meter running. If that happens, delete the entry.
* While the instance is stopped or suspended, the URL answers `503 { "error": "unavailable" }`, so visitors never see your billing state.
* The path `/health` is answered by the platform edge itself and never reaches your app on any instance URL, public ports included. Serve health checks on another path.
* Public ports and [signed URLs](/docs/agents-api/urls#browser-access-with-signed-urls) are orthogonal: deleting a public port does not invalidate signed URLs for that port, and vice versa.
# Sessions
Source: https://www.agent37.com/docs/agents-api/sessions
List, read, rename, and delete conversations on an instance.
A session is one conversation on an instance. An instance holds many sessions, one per thread, and each session keeps its own full history, so you only ever send the new input. These endpoints are served by the gateway running inside the instance.
The gateway keeps no session index of its own. The agent harness owns both the transcript and the list (Hermes' session store, OpenClaw's history), and the gateway projects them on read. So a session's fields are the harness's own, and a list or read always reflects the live state of that harness.
Everything on this page lives on the instance URL, not the hosting API: the base is `https://{instanceId}.agent37.app`, with the same `sk_live_` key sent as the `X-Agent37-Key` header. The platform edge authenticates the key and checks the instance belongs to your workspace, then the gateway answers. See [Instance and preview URLs](/docs/agents-api/urls).
## Endpoints
| Method | Path | Returns |
| -------- | ------------------- | ---------------------------------------------------------------------------------------------- |
| `GET` | `/v1/sessions` | `200` `{ "agent": "...", "data": [...] }`, the harness's session list, newest first |
| `GET` | `/v1/sessions/{id}` | `200` `{ "id", "agent", "active_response_id", "history" }`, the conversation's full transcript |
| `PATCH` | `/v1/sessions/{id}` | `200` `{ "id", "agent", "renamed" }`, rename a session (Hermes) |
| `DELETE` | `/v1/sessions/{id}` | `200` `{ "id": "...", "deleted": true }` |
You never create a session directly. The first [`POST /v1/responses`](/docs/agents-api/chat) without a `session_id` mints one and returns its id; reuse that id to continue the thread.
## Choosing the harness
Every read on this page takes an optional `?agent=` query, `hermes` or `openclaw`, that selects which harness on the instance answers. Omit it (or send it empty) and the instance's configured default harness answers; the response echoes which `agent` it was. An unknown value is `400 validation_error`, and targeting a harness the instance was not provisioned with is `503 agent_unavailable`. Your `agent37-hermes` Cloud instances serve Hermes, so you can leave `?agent=` off.
## The session object
There is no gateway-defined session shape. `GET /v1/sessions` passes each entry through from the harness's own store, native fields untouched, so the exact fields depend on the harness and can evolve with it.
The only field the gateway guarantees across harnesses. It is the session id you pass back to `GET`, `PATCH`, and `DELETE /v1/sessions/{id}`, and the `session_id` every response in the conversation carries. 32 hex characters, no prefix.
A Hermes session object also carries Hermes' own fields: `title`, `model`, `message_count`, `started_at`, `last_active`, and `preview` (see the example below). OpenClaw returns its own native fields. These come straight from the harness; the gateway does not normalize them, so field names, types, and timestamp units track the harness rather than this API (unlike a history message's `created_at`, which the gateway does normalize to epoch milliseconds). Read what you need by name; don't assume a field exists on every harness.
## List sessions
`GET /v1/sessions` returns the harness's sessions, newest first (Hermes orders by most recent activity), wrapped in `{ "agent": "...", "data": [...] }`. The `agent` names which harness the list is for. Pass `?agent=hermes` or `?agent=openclaw` to pick a harness; omit it for the instance default. The list carries session metadata only, never history, so it stays cheap to poll for a sidebar. A harness without a list API returns `data: []`.
```bash curl theme={null}
curl https://ab12cd34ef.agent37.app/v1/sessions \
-H "X-Agent37-Key: sk_live_..."
```
```python python theme={null}
import requests
sessions = requests.get(
"https://ab12cd34ef.agent37.app/v1/sessions",
headers={"X-Agent37-Key": "sk_live_..."},
).json()["data"]
```
```javascript node theme={null}
const { data: sessions } = await (await fetch(
"https://ab12cd34ef.agent37.app/v1/sessions",
{ headers: { "X-Agent37-Key": "sk_live_..." } },
)).json();
```
```json response theme={null}
{
"agent": "hermes",
"data": [
{
"id": "7f3e0b6c52a949d2b1c4a8e9d0f31726",
"title": "EV makers memo",
"model": "claude-sonnet-4-5",
"message_count": 4,
"started_at": 1781049600000,
"last_active": 1781049642000,
"preview": "Research the top 3 EV makers, write a memo."
}
]
}
```
## Retrieve a session with history
`GET /v1/sessions/{id}` returns `{ "id", "agent", "active_response_id", "history" }`: `history` is the full transcript, in order, projected from the harness. You read it for display or audit; you never resend it, because the session already holds it. An unknown id returns an empty `history` rather than a `404`, because the harness owns whether a session exists. Pass `?agent=` to pick the harness.
The id of the response currently running on the session, or `null` when it is idle. The harness writes a turn's messages at turn end, so while this is set the running turn is normally **not** in `history` yet. Follow it live with [`GET /v1/responses/{id}/stream`](/docs/agents-api/streaming#reconnect-after-a-drop). This is how a client that lost its state (page reload, new device) rediscovers a running turn and reattaches. Two timing edges: right at turn end, one read can briefly show the finished turn in `history` and its id still here; reattaching is still correct, the replay just ends immediately. And once it reads `null` the transcript is complete, with one exception: a cancelled OpenClaw turn is persisted by OpenClaw on its own schedule and can surface in `history` shortly after.
Each entry in `history` is a message:
The message id. Treat it as opaque; it uses a different format from session and response ids.
The session the message belongs to.
`user`, `assistant`, or `system`.
The message text.
The assistant's reasoning for that turn, when the agent recorded any. Absent otherwise.
When the message was created, in epoch milliseconds.
```bash curl theme={null}
curl https://ab12cd34ef.agent37.app/v1/sessions/7f3e0b6c52a949d2b1c4a8e9d0f31726 \
-H "X-Agent37-Key: sk_live_..."
```
```json response theme={null}
{
"id": "7f3e0b6c52a949d2b1c4a8e9d0f31726",
"agent": "hermes",
"active_response_id": null,
"history": [
{
"id": "hermes:7f3e0b6c52a949d2b1c4a8e9d0f31726:1",
"session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726",
"role": "user",
"content": "Research the top 3 EV makers, write a memo.",
"created_at": 1781049601000
},
{
"id": "hermes:7f3e0b6c52a949d2b1c4a8e9d0f31726:2",
"session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726",
"role": "assistant",
"content": "Here is the memo...",
"thinking": "Comparing deliveries, margins, and charging networks...",
"created_at": 1781049642000
}
]
}
```
## Rename a session
`PATCH /v1/sessions/{id}` sets a session's title, writing it straight into the harness's own store, and returns `{ "id", "agent", "renamed" }`. `renamed` is `false` when no session matched the id. Send the new title in the body:
The new title. Cannot be empty. Hermes enforces a unique, length-capped title: a title already used by another session returns `409 title_conflict`, and an over-long one returns `400 validation_error`.
Rename is only available on harnesses that natively store an editable title. Hermes supports it; a harness that does not returns `405 rename_unsupported`. Pass `?agent=` to pick the harness.
```bash curl theme={null}
curl -X PATCH https://ab12cd34ef.agent37.app/v1/sessions/7f3e0b6c52a949d2b1c4a8e9d0f31726 \
-H "X-Agent37-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "title": "EV makers memo" }'
```
```json response theme={null}
{ "id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "agent": "hermes", "renamed": true }
```
## Delete a session
`DELETE /v1/sessions/{id}` is a best-effort removal from the harness's store and returns `{ "id": "...", "deleted": true|false }`. `deleted` is `true` when a transcript was removed and `false` when nothing matched the id, so the call is idempotent: repeating it simply returns `deleted: false` rather than erroring. A harness with no delete route (OpenClaw) always returns `deleted: false` and keeps its copy. Pass `?agent=` to pick the harness.
```bash curl theme={null}
curl -X DELETE https://ab12cd34ef.agent37.app/v1/sessions/7f3e0b6c52a949d2b1c4a8e9d0f31726 \
-H "X-Agent37-Key: sk_live_..."
```
```json response theme={null}
{ "id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "deleted": true }
```
Deleting a session removes the conversation and its history from the harness, but leaves the instance (its files, memory, and connected accounts) untouched. The per-turn response receipts are in-memory and short-lived; they expire on their own.
## One turn at a time
A session runs one response at a time. Posting new input while a turn is in flight returns `409 session_busy`, normally with the running response's id in `error.response_id`. Reattach to it with [`GET /v1/responses/{id}/stream`](/docs/agents-api/streaming#reconnect-after-a-drop), cancel it with [`POST /v1/responses/{id}/cancel`](/docs/agents-api/chat), or start the new input on another session. Two sessions on the same instance run independently.
To see which models a harness can run, and to set the model per turn, see [Models](/docs/agents-api/models). For the readiness probe an app polls after create or start, see [Health & version](/docs/agents-api/health).
# SSH
Source: https://www.agent37.com/docs/agents-api/ssh
Open a real shell on an instance with your own SSH keypair: ssh, scp, sftp, port forwarding, and VS Code Remote SSH, on any template image.
`ssh` into any instance your workspace owns, with the keypair you already have. Once your public key is registered, the whole OpenSSH toolchain works: interactive shells with a PTY, `scp` and `sftp` for files, port forwarding with `-L`, and **VS Code Remote SSH** for editing straight on the box.
Nothing is installed in your image. The platform injects the SSH server into the sandbox at runtime, so SSH works on `agent37-hermes`, `agent37-openclaw`, and every [custom image](/docs/agents-api/custom-image) you build, including images that ship no `sshd` and no shell tooling of their own.
## Quickstart
```bash theme={null}
export AGENT37_API_KEY=sk_live_... # https://www.agent37.com/dashboard/cloud/api-keys
npx agent37 ssh setup
ssh ab12cd34ef.agent37.app
```
`ssh setup` is a one-time step and safe to re-run. It saves your API key to `~/.config/agent37/config.json` (prompting for it if `AGENT37_API_KEY` is not set), registers `~/.ssh/id_ed25519.pub` with your workspace under your machine's hostname (generating the keypair if you do not have one, and treating an already-registered key as done), and writes a managed block into `~/.ssh/config`:
```text ~/.ssh/config theme={null}
# >>> agent37 >>>
Host *.agent37.app
ProxyCommand agent37 tunnel %h
User root
ServerAliveInterval 60
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile ~/.ssh/id_ed25519
# <<< agent37 <<<
```
The block goes at the top of the file, because `ssh` keeps the first value it finds for each setting and a `Host *` stanza further down would otherwise override it. `IdentityFile` names the key `setup` registered, so the connection still works if your existing config sets `IdentitiesOnly yes`.
That block is the whole client-side configuration. `ProxyCommand` is what carries the SSH stream (there is no port to dial directly), and `setup` writes it as the absolute path of the CLI that wrote it, so it works without a global install. Run under `npx` that path is inside the npx cache, so `npm install -g agent37` is the durable form; if `ssh` ever reports the `ProxyCommand` is missing, re-run `agent37 ssh setup`. `User root` is the default login and works on any image. `ssh node@.agent37.app` logs in as the user the agent runs as instead, so the agent's CLI, config, and files are exactly where the session lands — use it (or set the `User` accordingly in your tool) when the point of the session is to drive the agent itself. `ServerAliveInterval` keeps an idle session from being dropped. Host-key checking is off because an instance regenerates its host key when the platform relocates it, and the tunnel is already TLS plus your API key, so a pinned known-hosts entry would warn on a legitimate move.
You do not have to touch `~/.ssh/config` at all. `agent37 ssh` connects with the same options inline, and takes plain `ssh` arguments after `--`:
```bash theme={null}
npx agent37 ssh ab12cd34ef
npx agent37 ssh ab12cd34ef -- -L 8080:localhost:3737
```
## How it works
`ssh` runs `agent37 tunnel` as its `ProxyCommand`, which pipes the SSH byte stream over a WebSocket to `https://{instanceId}-22022.agent37.app`, carrying your `sk_live_` key in the `X-Agent37-Key` header. The Agent37 edge authenticates that key, checks your workspace owns the instance, and hands the stream to the instance's SSH server, which then authenticates your SSH public key. The instance has no public IP and no listening port on the internet.
So there are two independent layers, and a connection needs both:
1. **Your workspace API key**, checked at the edge. Revoking the key closes the door for every SSH client using it.
2. **Your SSH private key**, checked by the SSH server inside the instance. The platform never sees it; only the public half is registered.
## Manage keys
`ssh setup` calls these for you. Use them directly to enroll a teammate's key, rotate keys from CI, or audit what is registered. The dashboard lists the same keys under **API keys** at [dashboard/cloud/api-keys](https://www.agent37.com/dashboard/cloud/api-keys).
| Method | Path | Returns |
| -------- | ------------------- | ---------------------------------------- |
| `POST` | `/v1/ssh-keys` | `201` with the key object |
| `GET` | `/v1/ssh-keys` | `200` `{ "data": [...] }`, newest first |
| `DELETE` | `/v1/ssh-keys/{id}` | `200` `{ "id": "...", "deleted": true }` |
### Register a key
One OpenSSH public key line, the contents of a `.pub` file: `ssh-ed25519 AAAA... you@laptop`. Accepted types are `ssh-ed25519`, `ssh-rsa`, `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`, `sk-ssh-ed25519@openssh.com`, and `sk-ecdsa-sha2-nistp256@openssh.com`. A private key, an unsupported type, or a malformed line returns `400 invalid_request`.
A label for the key, such as `laptop`. Optional: omit it and the key's trailing comment is used.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/ssh-keys \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d "{ \"public_key\": \"$(cat ~/.ssh/id_ed25519.pub)\", \"name\": \"laptop\" }"
```
```python python theme={null}
import pathlib
import requests
resp = requests.post(
"https://api.agent37.com/v1/ssh-keys",
headers={"Authorization": "Bearer sk_live_..."},
json={
"public_key": pathlib.Path("~/.ssh/id_ed25519.pub").expanduser().read_text().strip(),
"name": "laptop",
},
)
print(resp.json()["fingerprint"])
```
```javascript node theme={null}
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
const resp = await fetch("https://api.agent37.com/v1/ssh-keys", {
method: "POST",
headers: { Authorization: "Bearer sk_live_...", "Content-Type": "application/json" },
body: JSON.stringify({
public_key: readFileSync(`${homedir()}/.ssh/id_ed25519.pub`, "utf8").trim(),
name: "laptop",
}),
});
console.log((await resp.json()).fingerprint);
```
```json response theme={null}
{
"id": "9f2c1a7b4e0d6538ac91b2f4",
"name": "laptop",
"fingerprint": "SHA256:FO8faHNFPBZFVHE/8suuP499UfSYL1FkJUeEz8Ca0ms",
"type": "ssh-ed25519",
"created": 1781222420
}
```
The key's id. Pass it to `DELETE /v1/ssh-keys/{id}` to revoke.
The label you sent, the key's trailing comment when you omitted `name`, or `null` when the key carries no comment either.
The key's SHA256 fingerprint, the same string `ssh-keygen -lf ~/.ssh/id_ed25519.pub` prints. Compare it locally to confirm you registered the key you meant to.
The key type, for example `ssh-ed25519`.
When the key was registered, in epoch seconds.
A fingerprint registers once per workspace: sending a key that is already registered returns `409 ssh_key_exists`. A workspace holds at most 20 keys; the 21st returns `400 invalid_request`.
### List keys
```bash curl theme={null}
curl https://api.agent37.com/v1/ssh-keys \
-H "Authorization: Bearer sk_live_..."
```
```json response theme={null}
{
"data": [
{
"id": "9f2c1a7b4e0d6538ac91b2f4",
"name": "laptop",
"fingerprint": "SHA256:FO8faHNFPBZFVHE/8suuP499UfSYL1FkJUeEz8Ca0ms",
"type": "ssh-ed25519",
"created": 1781222420
}
]
}
```
Only your workspace's keys are listed, and the public key blob is not echoed back. Match on `fingerprint`.
### Delete a key
```bash curl theme={null}
curl -X DELETE https://api.agent37.com/v1/ssh-keys/9f2c1a7b4e0d6538ac91b2f4 \
-H "Authorization: Bearer sk_live_..."
```
```json response theme={null}
{ "id": "9f2c1a7b4e0d6538ac91b2f4", "deleted": true }
```
The delete acts once: repeating it returns `404 not_found`, and another workspace's key id returns the same `404`.
## Keys are workspace-wide
A registered key opens **every** instance your workspace owns, current and future. There are no per-instance keys.
Key changes reach running instances automatically, within about 25 seconds of the API call. An instance you create after registering a key has it from the moment it boots, with nothing to wait for.
## Copy files
`scp` and `sftp` work as they do anywhere, recursive copies included:
```bash theme={null}
scp ./report.csv ab12cd34ef.agent37.app:data/
scp -r ab12cd34ef.agent37.app:output ./output
sftp ab12cd34ef.agent37.app
```
For programmatic file movement from a backend, the instance's own [files endpoints](/docs/agents-api/files) are usually the better fit: they need no SSH key and no CLI.
## VS Code Remote SSH
With the `~/.ssh/config` block in place, the instance is an ordinary SSH host to VS Code. Install the **Remote - SSH** extension, run **Remote-SSH: Connect to Host**, and enter `ab12cd34ef.agent37.app`. The instance shows up in the host list on later connections.
VS Code installs its remote server into the instance on the first connect, so that connect is the slow one and later ones come up fast. Any editor that speaks plain SSH works the same way.
## Revoke
Two ways, neither on a timer. There are no TTLs and no expiring sessions, so access lasts until you take it away.
* **Delete the SSH key** (`DELETE /v1/ssh-keys/{id}`) to stop that keypair from logging in anywhere in the workspace.
* **Revoke the `sk_live_` API key** to stop every SSH client configured with it, whatever keypair it holds.
Revocation reaches running instances within about 25 seconds. It stops new logins; a session that is already open keeps running until it exits, so [restart](/docs/agents-api/instances#restart) or [stop](/docs/agents-api/instances#stop) the instance if you need live sessions cut immediately.
## Rules and limits
* **You log in as `root` by default; `node` is also accepted.** `ssh node@.agent37.app` lands as the user the agent runs as, with its config and files in place — prefer it when the session is about the agent, since files a root session writes into the agent's home can wedge the agent. On a [custom template](/docs/agents-api/custom-image), a non-root login works when your image has that account unlocked (`hermes-base` children do; otherwise add `usermod -p '*' node` — a Debian-locked account is refused even for key auth) — `root` works on any image. Password authentication is off: keys only.
* **An instance on an image published before 9 August 2026 accepts `root` only.** Its `node` account predates the unlock above, so [update](/docs/agents-api/instances#update) the instance once and `node` login works from then on.
* **An instance created before 3 August 2026 needs one restart.** The SSH server is injected when the sandbox is built, and a sandbox that is already running cannot be given one, so [restart](/docs/agents-api/instances#restart) that instance once (or [start](/docs/agents-api/instances#start) it, if it is stopped). Its disk is preserved; anything held in memory is not. Instances created since then have SSH from first boot.
* **A stopped instance refuses connections.** [Start](/docs/agents-api/instances#start) it first. Connecting to a sleeping instance [wakes it](/docs/agents-api/urls#sleeping-instances-wake-on-request), the same as any other request to its URLs.
* **An open session counts as activity.** Keepalives keep bytes moving, so an [auto-sleep](/docs/agents-api/instances#auto-sleep) instance stays awake for as long as you are connected, billing at the 4x awake rate. Close the session when you are done.
* **Port `22022` can never be exposed without a credential.** It is a reserved platform port: no [public port](/docs/agents-api/public-ports) and no [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls) can be minted for it, so a WebSocket carrying an authenticated `sk_live_` key is the only way in.
* **20 SSH keys per workspace**, one entry per fingerprint.
* SSH traffic is ordinary instance traffic and is not metered separately.
# Streaming
Source: https://www.agent37.com/docs/agents-api/streaming
Stream a reply as named Server-Sent Events from your instance URL, and reconnect without losing the answer.
Send `stream: true` on [a message](/docs/agents-api/chat) and the reply comes back as Server-Sent Events. Each event is named, so you can render text, reasoning, and tool activity live. Events arrive in order, and the terminal `response.completed` event carries the final `output_text` and `usage`.
The base URL is your instance URL: `https://{instanceId}.agent37.app`, with the same `sk_live_` key sent as the `X-Agent37-Key` header on every request. This page documents the gateway's streaming contract, the API every instance serves.
## Start a stream
```bash curl theme={null}
curl -N 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.",
"stream": true
}'
```
The connection stays open and frames arrive as `event:` plus `data:` pairs separated by a blank line:
```text stream theme={null}
event: response.created
data: {"id":"c91d2a7e84f04b6f9a3d5e1c0b87f4a2","session_id":"7f3e0b6c52a949d2b1c4a8e9d0f31726"}
event: response.reasoning.delta
data: {"text":"Comparing deliveries and margins across the big three..."}
event: response.tool_call.started
data: {"tool":"web_search","label":"EV deliveries 2025"}
event: response.tool_call.completed
data: {"tool":"web_search","duration_ms":1840}
event: response.output_text.delta
data: {"text":"## EV market memo\n\n"}
:keepalive
event: response.completed
data: {"output_text":"## EV market memo\n\n...","usage":{"input_tokens":1840,"output_tokens":920,"cost_usd":0.0137}}
```
## Events
There are exactly eight event types:
| Event | Payload |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `response.created` | `{ id, session_id }`, always first; the response id and the session it runs in |
| `response.reasoning.delta` | `{ text }`, a chunk of the agent's thinking |
| `response.output_text.delta` | `{ text }`, a chunk of the visible answer |
| `response.tool_call.started` | `{ tool, label? }` |
| `response.tool_call.completed` | `{ tool, duration_ms? }` |
| `response.tool_call.failed` | `{ tool, error? }`, the run continues |
| `response.completed` | `{ output_text, usage }`, terminal; `usage` can be `null`, and `cost_usd` inside it is `null` when the provider reports no cost |
| `response.failed` | `{ error: { code, message, param?, hint? } }`, terminal |
These event names and payloads are the gateway's streaming contract, not a per-agent detail. Hermes and OpenClaw emit them today, and the agents that follow will emit the same eight, so your client code does not change when you switch templates.
Rules the stream always follows:
* `response.created` is always first, and exactly one terminal event (`response.completed` or `response.failed`) ends every live stream.
* Every 30 seconds the gateway writes the comment line `:keepalive`, whether or not events are flowing. Comments are not events: ignore any line starting with `:`.
* There is no `[DONE]` sentinel. The server closes the connection right after the terminal event; terminal event plus close is end of stream.
* Once streaming starts, failures arrive as a `response.failed` event with the standard error body, never as an HTTP error status.
A cancelled turn (`POST /v1/responses/{id}/cancel`) still ends with `response.completed`, carrying whatever `output_text` accumulated before the cancel. Only failures emit `response.failed`. The stored response's `status` is `cancelled`.
## Reconnect after a drop
```text theme={null}
GET /v1/responses/{id}/stream
```
If your connection drops mid-turn, reconnect with the response id from `response.created`. Lost the id too (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`; `null` means no turn is in flight (see that field's timing notes on the Sessions page).
```bash curl theme={null}
curl -N https://ab12cd34ef.agent37.app/v1/responses/c91d2a7e84f04b6f9a3d5e1c0b87f4a2/stream \
-H "X-Agent37-Key: sk_live_..."
```
While the run is live, the gateway replays the entire ordered event buffer from `response.created` onward, then stays attached for the rest of the run. If the run just finished, it replays the buffer and ends. The buffer holds up to 100,000 events per run; the rare run that exceeds it stops buffering, so a reconnect replays the first 100,000 events and may end without the terminal event. When that happens, wait for `active_response_id` on [`GET /v1/sessions/{id}`](/docs/agents-api/sessions#retrieve-a-session-with-history) to read `null`, then recover the final answer from the same call's `history`.
**Reconnect and the answer is still there.** Reconnect within about 30 minutes of a turn finishing and `/stream` still replays the final `output_text`. One caveat: about 60 seconds after a turn finishes, the in-memory event buffer expires and the replay is synthesized from the retained in-memory response record as `response.created`, one `response.output_text.delta` carrying the full text (omitted when the turn produced none), then the terminal event. Reasoning and tool-call events from the original run are not preserved in that synthesized replay. After the record expires (about 30 minutes, or on a gateway restart) `/stream` returns `404 response_not_found`; recover the answer from the [session transcript](/docs/agents-api/sessions) instead, which holds every finished turn. (The transcript holds finished turns only: the harness writes a turn's messages at turn end, so a still-running turn is never in it. To find a running turn, read `active_response_id` from `GET /v1/sessions/{id}`.)
## Parse the stream
No SSE library needed. Read the response body, split on the blank-line frame boundary, skip comment lines, and branch on each frame's `event:` line. Stop when the connection closes after a terminal event.
```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.",
stream: true,
}),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line
const frames = buffer.split("\n\n");
buffer = frames.pop(); // keep the trailing partial frame
for (const frame of frames) {
if (frame.startsWith(":")) continue; // comment line, e.g. :keepalive
const event = frame.match(/^event: (.+)$/m)?.[1];
const data = JSON.parse(frame.match(/^data: (.+)$/m)?.[1] ?? "{}");
switch (event) {
case "response.output_text.delta":
process.stdout.write(data.text); // stream the answer
break;
case "response.reasoning.delta":
// show the agent thinking, if you want
break;
case "response.tool_call.started":
console.log(`\n[${data.tool}] ${data.label ?? ""}`);
break;
case "response.completed":
console.log("\nusage:", data.usage);
break;
case "response.failed":
console.error("\nerror:", data.error);
break;
}
}
}
```
The browser's built-in `EventSource` cannot send a POST body or custom headers like `X-Agent37-Key`, so it cannot start a stream here. Use `fetch` as above, in the browser and in Node. The [hermes-chat example](https://github.com/agent37-platform/examples/tree/main/hermes-chat) runs a browser version of this parser in a real chat UI (`public/chat.js`), with reconnect and cancel wired in.
Prefer not to stream? Send `stream: false` (the default) and the call returns the finished response as one JSON body, with the agent's reply in `output_text`.
# Templates
Source: https://www.agent37.com/docs/agents-api/templates
Name an image once, then create every instance from that name.
A template is a named image, plus (optionally) the port its main service listens on. Instances are always created from template names: pass `template` on [`POST /v1/instances`](/docs/agents-api/instances), or omit it for the default `agent37-hermes`. Direct image references are rejected with `400 invalid_request`; register a template first, then pass its name.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "template": "agent37-hermes" }'
```
Templates come in two scopes. `system` templates are the built-in catalog, the same for every workspace and read-only. `workspace` templates are ones you register from your own image, either a public registry reference or a [cloud build](/docs/agents-api/custom-image#build-it-in-the-cloud), and they are visible only to your workspace.
Registering a workspace template copies your image once into private storage and pins it by digest. That snapshot is what instances run: registration takes up to a few minutes for a large image, the image is stored privately and never republished, and re-pushing the same registry tag later has no effect until you [update the template](#update-a-template).
## System catalog
The `agent37-` prefix is reserved for system templates. The catalog has three entries today:
| Template | What it runs |
| ---------------------- | -------------------------------------------------------------------- |
| `agent37-hermes` | Hermes, the general agent: chat, browsing, code, files. The default. |
| `agent37-hermes-small` | Hermes, lean: chat, code, files, shell. No browser or desktop. |
| `agent37-openclaw` | OpenClaw, the general agent: chat, headless browsing, code, files. |
Every template, system or your own, offers the same three shapes and starts at 2 vCPU / 4 GB, \$4.94 per month; see [sizing](/docs/agents-api/instances). `agent37-hermes-small` is worth picking when your agent does not need a browser or a desktop: the image is far smaller, so it pulls and boots faster and leaves more of the shape to your own work.
More system templates (Claude Code, Codex) are coming; they will appear in `GET /v1/templates` when they are available. Passing any other `agent37-` name on instance create returns `400 invalid_request`, because the prefix is reserved.
On every system template, port `3737` is the gateway: the [chat API](/docs/agents-api/chat) at `https://{instanceId}.agent37.app`. Any other port an instance serves, including the built-in dashboard, terminal, and file browser, is reachable at its derivable `https://{instanceId}-{port}.agent37.app` URL; see [Instance and preview URLs](/docs/agents-api/urls).
System templates track the newest platform image: their `image_ref` moves forward as new versions ship, and each create uses whatever the template points at in that moment. Existing instances are unaffected: an instance keeps the image it was created from until you [update it](/docs/agents-api/instances#update).
## Pin a template version
If you need every instance you create to be identical (say a setup script runs after each create and depends on exact paths or versions), pin the version: append `@` (system templates) or `@` (workspace templates) to the template name on create.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "template": "agent37-hermes@" }'
```
Every release is an immutable dated tag, never republished, so a pin is a freeze. The published tags are on GHCR: [hermes](https://github.com/orgs/agent37-platform/packages/container/package/hermes), [hermes-small](https://github.com/orgs/agent37-platform/packages/container/package/hermes-small), [openclaw](https://github.com/orgs/agent37-platform/packages/container/package/openclaw); the current tag is the one in each template's `image_ref` on `GET /v1/templates`. An unpublished tag returns `400 invalid_request`, and so does `@latest`, because the bare name already follows the latest release. To check a version before creating from it, `GET /v1/templates/agent37-hermes@`: it returns the template with the pinned `image_ref`, or `404` if that version isn't published.
Workspace templates pin by revision number: every image publish (a changed `image_ref` or a successful [cloud build](#build-an-image-in-the-cloud)) appends a release, `GET /v1/templates/{name}` lists them under [`revisions`](#the-template-object), and `my-agent@2` freezes revision `2`. `GET /v1/templates/my-agent@2` probes one the same way as a system tag, returning `404` if it was never published, and an unpublished revision on create or update returns `400 invalid_request` naming the published ones.
The pin sticks: the instance object's `template` field carries it (`"agent37-hermes@2026.07.02b"`, `"my-agent@2"`), and every lifecycle operation, including [`update`](/docs/agents-api/instances#update), stays on the pinned version. To pin an existing instance, move its pin, or clear it, pass a `template` to update: the name with an `@` or `@` sets or switches the pin, the bare name clears it and follows latest again.
## Endpoints
| Method | Path | Returns |
| -------- | -------------------------------- | --------------------------------------------------------------------------------------------- |
| `GET` | `/v1/templates` | `200` `{ "data": [...] }`, system catalog first, then your workspace templates sorted by name |
| `POST` | `/v1/templates` | `201` with the new workspace template |
| `GET` | `/v1/templates/{name}` | `200` with one template, system or workspace |
| `PATCH` | `/v1/templates/{name}` | `200` with the updated workspace template |
| `DELETE` | `/v1/templates/{name}` | `200` `{ "name": "...", "deleted": true }` |
| `POST` | `/v1/template-builds` | `201` with a new cloud build and a presigned URL for its build context |
| `POST` | `/v1/template-builds/{id}/start` | `200` with the build now `building` |
| `GET` | `/v1/template-builds/{id}` | `200` with the build, including the published template once it succeeds |
| `GET` | `/v1/template-builds/{id}/logs` | `200` with the build plus an incremental chunk of its build log |
```bash curl theme={null}
curl https://api.agent37.com/v1/templates \
-H "Authorization: Bearer sk_live_..."
```
## Build an image in the cloud
To publish a local or private image, you don't upload the image; you upload a small **build context** (a `Dockerfile` plus the files it `COPY`s) and Agent37 builds the image on its own infrastructure. No local Docker needed, and nothing has to be public except what the build fetches. The supported path is the Agent37 CLI:
```bash theme={null}
export AGENT37_API_KEY=sk_live_...
npx agent37 templates build ./my-agent --name my-agent
```
The directory defaults to `.` and must have a `Dockerfile` at its root. The CLI packs the **whole folder**, minus `.git` and your `.dockerignore` patterns (plain patterns only; `!` negations are ignored), gzips it (100 MB cap), and streams the live build log to your terminal; on failure it exits non-zero with the failing step visible. Every unexcluded file ships with the context, a stray `.env` included, and a `COPY . .` bakes it into the image, so check the folder, or add a `.dockerignore`, before you build. `--name` defaults to the folder name, and `--default-port ` sets the published 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 keeps running server-side.
See [Build a custom image](/docs/agents-api/custom-image) for the walkthrough. The raw flow below is for clients scripting the same thing without the CLI.
### 1. Create a build
`POST /v1/template-builds` with the template name and the exact byte count of your gzipped context:
The template the build publishes, following the same rules as [template names](#register-a-workspace-template). A new name is created at revision `1` when the build succeeds; an existing name gets a new revision.
Exact size of the gzipped context, from `1` through `100,000,000` bytes (100 MB).
Applied to the published template, with the same semantics as on [`POST /v1/templates`](#register-a-workspace-template).
Optional free-text description for the published template.
```bash curl theme={null}
tar --exclude-vcs -czf context.tar.gz -C ./my-agent .
BYTES=$(wc -c < context.tar.gz | tr -d ' ')
curl --fail-with-body -sS -X POST https://api.agent37.com/v1/template-builds \
-H "Authorization: Bearer $AGENT37_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\":\"my-agent\",\"size_bytes\":$BYTES}" > build.json
```
```json response theme={null}
{
"id": "tb_7d3a90b81f264bc2a113",
"name": "my-agent",
"status": "created",
"upload_url": "https://uploads.example.com/...signed...",
"expires_at": 1784192400,
"max_bytes": 100000000
}
```
The workspace-scoped build id, `tb_` followed by 20 lowercase hex characters.
A presigned `PUT` URL for the gzipped context, valid for one hour. It is a bearer credential: send no Agent37 key to it, and do not log or share it.
When `upload_url` expires, in epoch seconds.
The context cap: exactly `100,000,000` bytes (100 MB).
### 2. Upload the context
`PUT` the gzipped tar to `upload_url`. The archive must have `Dockerfile` at its root: pack the folder's contents, not the folder. Clear curl's default `Expect: 100-continue` header; it is incompatible with these presigned uploads:
```bash curl theme={null}
curl --fail-with-body -X PUT -H 'Expect:' \
--data-binary @context.tar.gz "$(jq -r '.upload_url' build.json)"
```
### 3. Start the build
```bash curl theme={null}
BUILD_ID=$(jq -r '.id' build.json)
curl --fail-with-body -sS -X POST \
"https://api.agent37.com/v1/template-builds/$BUILD_ID/start" \
-H "Authorization: Bearer $AGENT37_API_KEY"
```
```json response theme={null}
{ "id": "tb_7d3a90b81f264bc2a113", "status": "building" }
```
Starting a build whose context was never uploaded, or is over 100 MB, returns `400 invalid_request`. One build runs at a time per workspace: a build that already started, or a start while another build is running, returns `409 build_conflict`.
### 4. Poll it and tail the log
`GET /v1/template-builds/{id}` returns the build; `GET /v1/template-builds/{id}/logs?offset=N` returns the same fields plus a chunk of build log starting at `offset`. Pass the returned `offset` back to tail incrementally. Poll rather than wait silently: the poll that finds the build finished is what triggers template registration (an unpolled build is still completed by a platform sweep within about five minutes).
```bash curl theme={null}
curl -sS "https://api.agent37.com/v1/template-builds/$BUILD_ID/logs?offset=0" \
-H "Authorization: Bearer $AGENT37_API_KEY"
```
```json response theme={null}
{
"id": "tb_7d3a90b81f264bc2a113",
"name": "my-agent",
"status": "succeeded",
"template": {
"name": "my-agent",
"revision": 1,
"image_digest": "sha256:9b2e...c41f"
},
"created": 1784190000,
"started": 1784190010,
"finished": 1784190190,
"offset": 5120,
"data": "#8 exporting layers\n#8 DONE 1.2s\n"
}
```
`created` (waiting for the context and `start`), `building` (the Docker build is running), `ingesting` (build done; the image is being copied into private storage), `succeeded`, or `failed`.
On `failed`, the reason as `{ "code", "message" }`. See [the failure codes](#build-limits-and-failures).
On `succeeded`, the published result: the template `name`, its new `revision`, and the pinned `image_digest`.
When the build was created, in epoch seconds.
When the build started, in epoch seconds. Set once it starts.
When the build finished, in epoch seconds. Set once it succeeds or fails.
Logs endpoint only: the next offset to request.
Logs endpoint only: the log chunk starting at your offset, the real Docker build output. It stays readable after the build finishes.
### Build limits and failures
Builds are free. One build runs at a time per workspace, a build times out after 45 minutes, and the gzipped context is capped at 100 MB. The built image is capped at 8 GB decimal, like a registry import; if you need more, [talk to the team](https://cal.com/vishnukool/30min).
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; for a private base image, [contact support](mailto:support@agent37.com). A template published by a build has an `image_digest` and no `image_ref`, because no public reference exists.
A failed build carries its reason in `error.code`:
| Code | What happened |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `build_failed` | A Dockerfile step failed; the failing step is at the end of the build log |
| `build_timeout` | The build ran past the 45-minute limit |
| `invalid_request` | The context is unusable: not a gzipped tar, or no `Dockerfile` at its root |
| `invalid_image_archive` | The built image failed validation: not a readable single `linux/amd64` image |
| `image_too_large` | The built image is over the 8 GB decimal cap |
| `image_ingest_failed` | The copy into private storage failed for a reason on our side; re-run the build, or [contact support](mailto:support@agent37.com) |
| `internal_error` | The build failed for a reason on our side; re-run the build, or [contact support](mailto:support@agent37.com) |
## Register a workspace template
`POST /v1/templates` registers an amd64 image under a name you choose. The platform runs it as a managed instance with the same lifecycle, billing, [exec](/docs/agents-api/exec), and routed port URLs as the catalog; what the image serves on its ports is up to it. The image can be anything, built from scratch or on [the Hermes base image](#build-on-the-hermes-base-image). Give the image as an `image_ref`, a **public** registry reference; for an image that isn't public, use a [cloud build](#build-an-image-in-the-cloud) instead. It publishes the template itself, so there is nothing to register afterward. If you need an image larger than the 8 GB cap allows, [talk to the team](https://cal.com/vishnukool/30min).
2 to 63 characters: lowercase letters, digits, and hyphens, starting with a letter (`^[a-z][a-z0-9-]{1,62}$`). The `agent37-` prefix is reserved. A name that already exists returns `409 template_conflict`.
A fully qualified **public** image reference with a registry or namespace path, like `ghcr.io/acme/my-agent:v1`. Up to 255 characters. The image must be public (so the platform can copy it), built for `linux/amd64`, and at most 8 GB. On registration the platform pulls it once into private storage and pins it by digest; a reference that isn't publicly pullable, isn't `linux/amd64`, or is over the cap is rejected then.
Optional free-text description.
The port the bare instance URL (`https://{instanceId}.agent37.app`) routes to, and the port the platform probes at boot to decide the create succeeded. Your service must bind it on `0.0.0.0`, not only localhost. An integer 1 to 65535; the reserved ports `7681`, `8080`, `6080`, `7890`, `9119`, and `22022` ([SSH](/docs/agents-api/ssh)) are rejected (they belong to the managed runtime). Omitted, the bare URL falls back to `3737` (right for images built on [the Hermes base image](#build-on-the-hermes-base-image), whose gateway serves it) and no TCP boot probe runs. A private sandbox can listen on nothing and be driven by [exec](/docs/agents-api/exec), but its main process must still stay running. Every other port needs no declaration: it is already reachable at `https://{instanceId}-{port}.agent37.app`. With [auto-sleep](/docs/agents-api/instances#auto-sleep) on, a sandbox nothing ever requests only wakes by an explicit [`start`](/docs/agents-api/instances#start).
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/templates \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "my-agent",
"image_ref": "ghcr.io/acme/my-agent:v1",
"description": "My agent",
"default_port": 8000
}'
```
```python python theme={null}
import requests
resp = requests.post(
"https://api.agent37.com/v1/templates",
headers={"Authorization": "Bearer sk_live_..."},
json={
"name": "my-agent",
"image_ref": "ghcr.io/acme/my-agent:v1",
"description": "My agent",
"default_port": 8000,
},
)
print(resp.json())
```
```javascript node theme={null}
const resp = await fetch("https://api.agent37.com/v1/templates", {
method: "POST",
headers: {
Authorization: "Bearer sk_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "my-agent",
image_ref: "ghcr.io/acme/my-agent:v1",
description: "My agent",
default_port: 8000,
}),
});
console.log(await resp.json());
```
Registration copies the image into private storage before it returns, so `POST` and `PATCH` are synchronous and can take up to a few minutes for a large image. A reference that isn't publicly pullable, isn't `linux/amd64`, or is over the cap is rejected at registration with `400 invalid_request` or `400 image_too_large`, so a broken image never reaches instance create.
Pin a tag on `image_ref`; do not rely on `latest`. A pinned tag makes rollback a one-field `PATCH` and keeps every instance you create reproducible. The template is a snapshot regardless; pinning is about which build you re-register next.
Build for `linux/amd64` even on an Apple Silicon Mac (`docker build --platform linux/amd64 ...`). An arm64 image is rejected during registration.
## Build on the Hermes base image
[`ghcr.io/agent37-platform/hermes-base`](https://github.com/orgs/agent37-platform/packages/container/package/hermes-base) is the published FROM target for custom templates. It ships Hermes with its browser stack (Chromium, Playwright), the gateway that serves the [chat API](/docs/agents-api/chat), and a general toolchain: git, Python 3 with uv, Node.js, build tools. The image itself wires no model and no integrations; you add your layers on top. It does not need to carry credentials, because the platform puts a working LLM endpoint and a Composio MCP server in every instance's environment; see [Managed services in your image](/docs/agents-api/managed-services). Point your agent at those, or bring your own model keys.
```dockerfile theme={null}
FROM ghcr.io/agent37-platform/hermes-base:latest
USER root
RUN apt-get update && apt-get install -y postgresql-client
USER node
```
Publish it with a [cloud build](#build-an-image-in-the-cloud) (`npx agent37 templates build` from the folder holding the Dockerfile), or build it yourself for `linux/amd64` and push it to a public registry. OpenClaw builds have the same shape: the FROM target is [`ghcr.io/agent37-platform/openclaw-base`](https://github.com/orgs/agent37-platform/packages/container/package/openclaw-base), published with the same dated tags as `agent37-openclaw`.
See [Build a custom image](/docs/agents-api/custom-image) for the step-by-step walkthrough, and [agent37-platform/custom-agent-image](https://github.com/agent37-platform/custom-agent-image) for a working app that builds its own image and installs a skill into every agent it creates.
The contract:
* Your built image freezes the base it was built from; the FROM tag only matters when you rebuild. `latest` tracks the newest base, so getting-started builds never go stale. For reproducible rebuilds, pin a date tag: `hermes-base` publishes the same dated tags as `agent37-hermes` ([list on GHCR](https://github.com/orgs/agent37-platform/packages/container/package/hermes-base)), and the current one is the tag in `agent37-hermes`'s `image_ref` on `GET /v1/templates`.
* Keep the entrypoint. It starts Hermes and the gateway; a Dockerfile that overrides `ENTRYPOINT` loses the chat API.
* Bake outside `/home/node` and `/home/linuxbrew`. Both are persistent volumes, so anything the image writes there is masked at runtime. Use `/usr/local` or `/opt`.
* An image ships binaries, not skills. Hermes reads skills from `~/.hermes/skills`, on that same masked volume, so write them into a running instance over [exec](/docs/agents-api/exec) or the [files API](/docs/agents-api/files) instead, once per instance, at whatever point your app creates one. They persist across restarts and updates.
* The image runs as the `node` user, and `sudo` does not elevate at runtime (the sandbox ignores setuid). Switch to `USER root` for installs in the Dockerfile and back to `USER node` at the end; on a running instance, get root with [`ssh root@`](/docs/agents-api/ssh).
* Leave the default port to the gateway. The runtime tells the gateway which port to bind (`default_port`, or `3737` when you omit it), so the bare instance URL serves the chat API; do not bind your own service there. Your own services need no declaration: each is reachable at `https://{instanceId}-{port}.agent37.app`.
* Bring your own model keys. Instances boot with no LLM provider configured, and chat returns errors until you add one. Write your provider credentials into `~/.hermes/config.yaml`, Hermes' standard config file, over [exec](/docs/agents-api/exec) or the terminal. The file lives on the persistent volume, so it survives restarts and updates.
## Update a template
`PATCH /v1/templates/{name}` changes any of `name`, `image_ref`, `description`, or `default_port` (send `null` to clear it back to the `3737` fallback). Send at least one field. A changed `image_ref` is copied into private storage, so an image update is synchronous like create, and each successful image update increments the platform-controlled `revision`. Sending the exact same `image_ref` string reuses the existing snapshot and does not increment it; renaming or changing only `description` or `default_port` also leaves it unchanged. To ship a new image on a template published by a [cloud build](#build-an-image-in-the-cloud) (no `image_ref`), run a new build under the same name; every successful build publishes a new revision, even when the built image's digest is unchanged. Renaming onto an existing name returns `409 template_conflict`; system templates return `403`.
```bash curl theme={null}
curl -X PATCH https://api.agent37.com/v1/templates/my-agent \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "image_ref": "ghcr.io/acme/my-agent:v2" }'
```
To roll back, [pin instances to the earlier revision](#pin-a-template-version) with `POST /v1/instances/{id}/update` and `{ "template": "my-agent@1" }`. No re-publish needed. To make an old release the template's *current* one again, point `image_ref` at the older tag (or, for a build-born template, re-run the [build](#build-an-image-in-the-cloud) from the older source); that publishes it as a new revision. Existing instances keep running on the image they already pulled; [update each instance](/docs/agents-api/instances) to recreate it from the template's current image.
Workspace templates start at `revision: 1`. Compare that value with an instance's `template_revision` to find instances that have not installed the current template release.
## Roll out a release to your fleet
After an image update, list your instances and [update](/docs/agents-api/instances#update) the ones still on an older revision. An instance's `template_revision` is what it has installed; the template's `revision` is the current release. Missing instance revisions read as `1`.
```bash curl theme={null}
TEMPLATE=my-agent
REV=$(curl -s https://api.agent37.com/v1/templates/$TEMPLATE \
-H "Authorization: Bearer sk_live_..." | jq .revision)
curl -s https://api.agent37.com/v1/instances \
-H "Authorization: Bearer sk_live_..." |
jq -r --arg t "$TEMPLATE" --argjson r "$REV" \
'.data[] | select(.template == $t and (.template_revision // 1) != $r) | .id' |
head -n 3 |
xargs -I{} curl -s -X POST "https://api.agent37.com/v1/instances/{}/update" \
-H "Authorization: Bearer sk_live_..."
```
The `head -n 3` is the canary: update a small slice first, verify the release on those instances, then rerun without it to finish the fleet. Rerunning is always safe, the filter only picks up instances still behind. A `sleeping` instance cannot be updated; wake it with [`start`](/docs/agents-api/instances#start) first.
## Delete a template
`DELETE /v1/templates/{name}` removes the workspace template and acts once: the first call returns `200` with `{ "name": "my-agent", "deleted": true }`, and a repeat returns `404`. System templates return `403`. Existing instances created from it keep running, so deletion does not immediately purge the private image copy they still reference. Contact support if you need the stored image purged after its instances are gone.
```bash curl theme={null}
curl -X DELETE https://api.agent37.com/v1/templates/my-agent \
-H "Authorization: Bearer sk_live_..."
```
## The template object
```json response theme={null}
{
"name": "agent37-hermes",
"scope": "system",
"agents": ["hermes"],
"revision": null,
"image_ref": "ghcr.io/agent37-platform/hermes:2026.07.02b",
"description": "Hermes general agent: browser, code, files.",
"default_port": 3737,
"created": null,
"updated": null
}
```
A workspace template registered from a public reference echoes that reference as `image_ref` and adds the pinned `image_digest`; one published by a [cloud build](#build-an-image-in-the-cloud) has an `image_digest` and no `image_ref`:
```json response theme={null}
{
"name": "my-agent",
"scope": "workspace",
"agents": [],
"revision": 2,
"image_digest": "sha256:9b2e...c41f",
"description": "My agent",
"default_port": 8000,
"created": 1752460800,
"updated": 1752547200,
"revisions": [
{ "revision": 2, "image_digest": "sha256:9b2e...c41f", "created": 1752547200 },
{ "revision": 1, "image_digest": "sha256:41d8...77aa", "created": 1752460800 }
]
}
```
The name you pass as `template` when creating an instance.
`system` for built-in `agent37-` templates, `workspace` for ones you register.
The agents the template installs: `["hermes"]` on `agent37-hermes`. Workspace templates report `[]`, because the platform does not inspect what your image installs.
The platform-controlled workspace image revision. It starts at `1` and increments for a changed `image_ref` or every successful [cloud build](#build-an-image-in-the-cloud), even when the digest is unchanged. Metadata-only updates and the same exact `image_ref` leave it unchanged. `null` for system templates.
Every published release of a workspace template, newest first: `{ "revision": 2, "image_digest": "sha256:...", "created": 1752547200 }`. [Pin one](#pin-a-template-version) with `name@` on instance create or update. Returned on `GET /v1/templates/{name}` only; absent from the list endpoint and from system templates. Templates that predate release history list only the revisions published since.
For system templates and workspace templates registered from a registry, the image reference. **Absent** on a workspace template published by a [cloud build](#build-an-image-in-the-cloud), whose image lives only in Agent37's private storage, identified by `image_digest`.
On workspace templates, the `sha256:...` digest of the private snapshot instances run, the immutable identity of the registered image. Absent on system templates.
Your optional description.
The declared default port, or `null` when the template relies on the `3737` fallback. See [Instance and preview URLs](/docs/agents-api/urls).
Creation time in epoch seconds. `null` for system templates.
Last update time in epoch seconds. `null` for system templates.
## Errors
| Status | Code | When |
| ------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400` | `invalid_request` | Bad `name`, `image_ref`, or `default_port`; a template create with no `image_ref`; a not-publicly-pullable registry reference; the legacy `ports` array; a direct image reference passed as `template` on instance create; a bad [pin](#pin-a-template-version) on create or update (unpublished system tag, `@latest`, non-numeric or unpublished workspace revision); a [build](#build-an-image-in-the-cloud) `start` with no uploaded context or one over 100 MB |
| `400` | `image_too_large` | The staged image is over the 8 GB decimal cap |
| `403` | `forbidden` | `PATCH` or `DELETE` on a system template: they are read-only |
| `404` | `not_found` | Unknown template name or build id; a pinned `GET` for an unpublished version or revision; repeat `DELETE` |
| `409` | `template_conflict` | Create or rename onto a name that already exists |
| `409` | `build_conflict` | A build `start` on a build that already started, or while another build is running in your workspace (one build runs at a time) |
| `502` | `image_ingest_failed` | The copy into private storage failed for a reason on our side; retry, or [contact support](mailto:support@agent37.com) |
A build that fails after starting is not an HTTP error: `GET /v1/template-builds/{id}` returns `200` with `status: "failed"` and the reason in its `error` field. See [build limits and failures](#build-limits-and-failures).
See [Errors](/docs/agents-api/errors) for the envelope and the full catalog.
# Instance and preview URLs
Source: https://www.agent37.com/docs/agents-api/urls
Reach the software running inside an instance over HTTPS, at instance and preview URLs you can derive from the instance id.
Every instance with a default port is reachable at `https://{instanceId}.agent37.app`. The instance id is the DNS label, so instance `ab12cd34ef` lives at `https://ab12cd34ef.agent37.app`: you can construct the URL from the id alone, with no lookup step.
Instance URLs are the Agent API plane: you reach what runs *inside* an instance here, authenticated by the same `sk_live_` key as the hosting API, sent as the `X-Agent37-Key` header. See [Core concepts](/docs/agents-api/concepts).
## Every port has a URL
Nothing is declared and nothing is looked up; both URL forms are derivable from the instance id alone:
* **Instance URL**: `https://{instanceId}.agent37.app`, the bare id. It routes to the template's [`default_port`](/docs/agents-api/templates#register-a-workspace-template) (`3737`, the gateway, unless the template says otherwise), and it is the `url` field on every instance object.
* **Preview URL**: `https://{instanceId}-{port}.agent37.app`, for **any** port, no registration needed. Preview URLs serve your own services plus the agent's built-in dashboard, terminal, and file browser.
A port nothing listens on answers with a transport error (`502`) rather than a `404`: the URL always routes; whether something serves is up to the instance.
You can also serve these on your own domain: register a [custom domain](/docs/agents-api/domains) and every URL here (the instance URL, preview URLs, and every public-port hostname) is mirrored at `{label}.yourdomain.com` with the same authentication.
### What `agent37-hermes` serves
| Port | URL | Serves |
| ------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `3737` | `https://ab12cd34ef.agent37.app` (default) | The gateway: [chat](/docs/agents-api/chat) at `/v1/responses`, plus sessions, [files](/docs/agents-api/files), models, health |
| `9119` | `https://ab12cd34ef-9119.agent37.app` | Hermes dashboard (browser) |
| `7681` | `https://ab12cd34ef-7681.agent37.app` | A shell in the container (browser) |
| `8080` | `https://ab12cd34ef-8080.agent37.app` | File browser for the workspace (browser) |
So for the default [template](/docs/agents-api/templates), chat is just the bare URL plus a path: `POST https://ab12cd34ef.agent37.app/v1/responses`. The dashboard, terminal, and file browser live on preview URLs; mint a [signed URL](#browser-access-with-signed-urls) to open one in a browser.
### What `agent37-openclaw` serves
| Port | URL | Serves |
| ------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `3737` | `https://ab12cd34ef.agent37.app` (default) | The gateway: [chat](/docs/agents-api/chat) at `/v1/responses`, plus sessions, [files](/docs/agents-api/files), models, health |
| `18789` | `https://ab12cd34ef-18789.agent37.app` | OpenClaw dashboard, its Control UI (browser) |
| `7681` | `https://ab12cd34ef-7681.agent37.app` | A shell in the container (browser) |
| `8080` | `https://ab12cd34ef-8080.agent37.app` | File browser for the workspace (browser) |
Same shape as Hermes: chat is the bare URL plus a path, and the dashboard, terminal, and file browser sit on preview URLs. The dashboard here is OpenClaw's own Control UI, served on `18789` instead of Hermes's `9119`.
## Authentication
A port accepts either credential, so you can reach the same port two ways:
* **API key header** (`X-Agent37-Key`) for API calls.
* **Signed URL** for handing a browser tab to a person.
There is a third option for callers that can't send either, webhooks most commonly: give the port its own permanent unauthenticated URL. See [Public ports](/docs/agents-api/public-ports).
### API key header (programmatic)
Every request to an instance URL can carry the same workspace API key as the hosting API, sent raw in the `X-Agent37-Key` header:
```
X-Agent37-Key: sk_live_...
```
The platform edge authenticates the key, checks that the instance belongs to your workspace, and forwards the request to the instance's port. A request without a credential gets `401`; a key from another workspace gets `404`.
Requests are forwarded to your instance with `Authorization` untouched, so software inside the instance can run its own Bearer authentication; the platform never forwards an `sk_live_` key into an instance. Earlier versions authenticated instance URLs with `Authorization: Bearer sk_live_...` instead; that form still works but is deprecated. See [Migrating from `Authorization: Bearer`](#migrating-from-authorization-bearer).
The key travels in a header. To open a preview URL like the dashboard, terminal, or file browser in a browser, mint a signed URL instead.
### Browser access with signed URLs
A signed URL is a time-boxed link a browser can open with no header, usually a preview URL. Mint one for any exposed port on the hosting API:
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/signed-url \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "port": 9119 }'
```
```python python theme={null}
import requests
r = requests.post(
"https://api.agent37.com/v1/instances/ab12cd34ef/signed-url",
headers={"Authorization": "Bearer sk_live_..."},
json={"port": 9119},
)
print(r.json()["url"])
```
```javascript node theme={null}
const res = await fetch("https://api.agent37.com/v1/instances/ab12cd34ef/signed-url", {
method: "POST",
headers: { Authorization: "Bearer sk_live_...", "Content-Type": "application/json" },
body: JSON.stringify({ port: 9119 }),
});
console.log((await res.json()).url);
```
```json response theme={null}
{
"url": "https://ab12cd34ef-9119.agent37.app/?a37_token=6a2b...e1f0",
"domain_urls": [],
"port": 9119,
"expires_at": 1717999200
}
```
The browser-openable URL. The first request promotes the token to a cookie that expires with it, so the page's own assets and WebSocket connections authenticate without it.
The same link on each of the workspace's active [custom domains](/docs/agents-api/domains), oldest first. The token is bound to the label, not the host, so every entry works. Empty without one.
The port the URL routes to.
Unix seconds when the URL stops working. Mint a fresh one when it expires.
The instance must be running or sleeping (opening the link is exactly the kind of request that [wakes it](#sleeping-instances-wake-on-request)); any `port` from 1 to 65535 mints, except `22022`, the [SSH](/docs/agents-api/ssh) port, which no credential-free URL can ever reach. An optional `ttl_seconds` sets how long the URL lives, default `3600` (one hour), clamped to `[60, 604800]` (one minute to seven days); pass a short value for a quick preview link, or the max to keep a terminal or dashboard tab open for a week. A missing or invalid port, or a `ttl_seconds` that is not a positive integer, returns `400`; an unknown or cross-workspace instance returns `404`.
The token rides in the `a37_token` query param. The edge consumes it (promoting it to the cookie) and strips it before forwarding, so it never reaches the instance and never collides with a query param your own app uses.
For user-facing browser access, mint the signed URL on demand and hand the link to the browser. The link is the only credential it carries, and it expires. A minted link cannot be revoked before it expires, so pick the shortest TTL your flow tolerates: a long-lived tab is worth seven days, a link in an email is not.
### Migrating from `Authorization: Bearer`
Earlier versions authenticated instance URLs with `Authorization: Bearer sk_live_...`. That form still works, but it is deprecated and will eventually stop being accepted; every workspace still sending it gets an email before that happens. It is the same key either way, so there is nothing to rotate, nothing to mint, and nothing to change in the dashboard.
Rename the header on every request that targets an instance URL: the bare instance URL, preview URLs, and any [custom domain](/docs/agents-api/domains) mirror of either.
```diff theme={null}
curl https://ab12cd34ef.agent37.app/v1/responses \
- -H "Authorization: Bearer sk_live_..." \
+ -H "X-Agent37-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "input": "Research the top 3 EV makers, write a memo." }'
```
Leave the hosting API as it is. `https://api.agent37.com` keeps `Authorization: Bearer sk_live_...` and always will. One key, two planes: talking to us is `Authorization`, talking to your instance is `X-Agent37-Key`.
This is a straight swap, not a staged rollout: the old form keeps working until it is removed, so rename the header and ship. If a request does carry both, `X-Agent37-Key` is the one consulted, and an invalid value there is a `401` with no fallback to `Authorization`.
The reason for the change is that `Authorization` belongs to your software. With the platform out of that header, an app running inside your instance receives its caller's own `Bearer` untouched, including agent software that does its own token auth, such as the Hermes dashboard on `9119`. The single exception is the platform key itself: an `Authorization` value beginning with `Bearer sk_live_` is stripped at the edge and never enters an instance, on any path.
## Call an instance
Hit the bare URL directly. The gateway's health endpoint is a quick reachability check:
```bash curl theme={null}
curl https://ab12cd34ef.agent37.app/v1/health \
-H "X-Agent37-Key: sk_live_..."
```
```python python theme={null}
import requests
r = requests.get(
"https://ab12cd34ef.agent37.app/v1/health",
headers={"X-Agent37-Key": "sk_live_..."},
)
print(r.json())
```
```javascript node theme={null}
const res = await fetch("https://ab12cd34ef.agent37.app/v1/health", {
headers: { "X-Agent37-Key": "sk_live_..." },
});
console.log(await res.json());
```
```json response theme={null}
{ "ok": true, "agent": "hermes", "healthy": true, "hermes": true }
```
Use the gateway's `/v1/health` for this, not `/health`: the bare path `/health` is reserved by the platform edge, which answers `{ "ok": true }` itself, without authentication and without reaching your instance.
## Sleeping instances wake on request
An [auto-sleep](/docs/agents-api/instances#auto-sleep) instance that has gone to sleep keeps every one of its URLs. A request to any of them wakes it transparently: the edge holds the request while the instance restores, then forwards it, usually well under a second (a few seconds when the wake falls back to a fresh boot). No status polling, no retry logic; the response you get is the response to the request you sent.
A wake can be refused or fail, and those answers use the same flat error shape as the other [transport errors](/docs/agents-api/errors#transport-errors):
| Code | HTTP | When |
| -------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `instance_suspended` | 402 | The workspace balance is negative and the instance is suspended. No wake is attempted; top up and retry. See [Billing](/docs/agents-api/billing#past-due-and-suspension). |
| `wake_timeout` | 503 | The wake did not complete within about 3 minutes. The instance keeps waking in the background; retry. |
| `wake_failed` | 503 | The wake failed. The instance stays `sleeping` and the next request tries again. |
## HTTP, SSE, and WebSocket
Plain HTTP requests, SSE streams, and WebSocket connections all pass end to end. [Streaming chat](/docs/agents-api/streaming) with `stream: true` works on the bare URL, and the connection stays open until the server closes it after the terminal event. WebSocket upgrades pass through too: that is what the browser terminal uses for its interactive shell.
## Private sandboxes
A [template](/docs/agents-api/templates) whose image listens on nothing still runs fine: with `default_port` omitted, boot is not probed, and every URL simply answers a transport error because nothing serves. Drive the instance from the hosting API with [exec](/docs/agents-api/exec), which runs shell commands inside it without touching any port.
# Agent37 White-Label Dashboard
Source: https://www.agent37.com/docs/agents-api/white-label
A white-label, multi-tenant agent dashboard built on the Agent37 API. Fork it, point it at your key, rebrand it, and ship the fastest way to put Agent37 in front of your users.
The **Agent37 White-Label Dashboard** is a complete agent app built entirely on the public Agent37 API. It is a multi-tenant dashboard for creating and managing agent [instances](/docs/agents-api/instances), with a native chat, file browser, and app integrations for each agent. Fork it, point it at your `sk_live_` key, change the name and logo, and deploy: you get a branded product on top of Agent37 without building the control plane or the agent UIs yourself.
The dashboard, ready to fork. Next.js and Supabase, deploys to Vercel. Clone it, add your keys, run `npm run setup`, and rebrand. (Formerly the `whitelabel` repo; the URL still redirects.)
## What you get
* **Multi-tenant from the start.** Email-and-password sign-in through Supabase (open signup, no verification), with workspaces, team members, and invitations. Each agent is scoped to a workspace, and one user can belong to several.
* **Full instance management.** Create, start, stop, restart, resize, roll to a new image, and delete instances: every [instance](/docs/agents-api/instances) action wrapped in a UI, with per-agent [budgets](/docs/agents-api/budgets) and usage.
* **A native workspace per agent.** Click an agent to open a tabbed workspace built right into the dashboard: **Chat**, **Files**, **Integrations**, and **Settings**. Chat streams responses through the [Agent API](/docs/agents-api/chat), and the file browser lists, reads, and writes the agent's files through the [Files API](/docs/agents-api/files).
* **One-click access to each agent's own UIs.** Alongside the native tabs, the dashboard mints [signed URLs](/docs/agents-api/urls#browser-access-with-signed-urls) to open an instance's built-in terminal, file browser, and dashboard in a new tab, so both styles of access ship out of the box.
* **App integrations per agent.** Connect Gmail, Slack, Notion, and more to each instance through the [App integrations](/docs/agents-api/integrations) endpoints, so your users authorize their own accounts from your branded UI.
* **Your choice of agent.** The create screen offers a curated catalog (Hermes and OpenClaw on Agent37's stock images) or [your own image](/docs/agents-api/custom-image), built from the included scaffold.
* **Your key stays server-side.** Every Agent37 call goes through the app's own backend (a BFF); the `sk_live_` key is never exposed to the browser.
## Get started
You need a funded workspace and an `sk_live_` [API key](https://www.agent37.com/dashboard/cloud/api-keys), plus a Supabase access token; setup provisions a free Supabase project for you. See [Billing](/docs/agents-api/billing) to fund the wallet.
```bash theme={null}
git clone https://github.com/agent37-platform/starter-kit
cd starter-kit
```
The fastest path is agent-driven: open the folder in Claude Code or Codex and paste the setup prompt from the README. It installs dependencies, asks for your two keys, provisions Supabase, and starts the app.
To do it by hand, run `npm install`, then `npm run setup` and paste your keys when prompted; it creates the Supabase project, runs the migration, and enables email auth.
```bash theme={null}
npm run dev
```
Open `http://localhost:3000` and sign up with an email and password. The repo's `SETUP.md` has the authoritative steps, a manual Supabase path, and the Vercel deploy guide.
## Make it yours
Rebranding is the point, and most of it is configuration:
* **Name and logo.** Set `appName` and `logoUrl` in `src/config/branding.ts`. Branding is code-side now, so it's versioned with your fork (not env-driven).
* **Colors and theme.** Tailwind, in the app's styles.
* **Your domain.** Deploy to Vercel and point your domain at it.
When you deploy, set `AGENT37_API_KEY`, the public Supabase variables, and the server-only `SUPABASE_SERVICE_ROLE_KEY`, and keep the setup-only secrets (the Supabase access token and database password) out of production. The repo's `SETUP.md` lists exactly which variables to set where.
## What it does and doesn't do
The dashboard runs entirely on your Agent37 [wallet and budgets](/docs/agents-api/billing): it sets a managed-spend cap per agent and shows usage, but it does not bill your end users. Payments are intentionally excluded, so add your own billing (the create route marks where an entitlement gate would go) when you charge them.
It is a **client** of the Agent37 API, not a reimplementation of it: the chat and file tabs proxy the [Agent API](/docs/agents-api/chat) through the app's backend, and everything the app can do is a subset of the public `/v1` surface. The API, not this code, is the authority on what an agent can and cannot do.
Want to build the experience yourself instead of forking the dashboard? See [Build a chat app](/docs/agents-api/chat-app) for the integration pattern, or [Build a custom image](/docs/agents-api/custom-image) to change what the agent can do. Want the same app on **your own Composio** instead of managed integrations? See [Use your own Composio](/docs/agents-api/composio).
# Quickstart
Source: https://www.agent37.com/docs/index
Create an agent and stream its first reply in two API calls.
Agent37 Cloud gives every user their own hosted agent computer. Create an instance and Hermes comes back running at its own URL: it chats, streams, browses, runs tools, and keeps state between conversations. Files, connected accounts, and memory stay on the agent until you delete it. You create the agent with one call, then talk to it with the next; you never touch a server.
**Building with an AI coding agent?** Point it at **[llms-full.txt](https://www.agent37.com/docs/llms-full.txt)**, the entire API in one file, and it can scaffold a working client.
**Three ways to build on Agent37:**
Fork a finished multi-tenant dashboard, rebrand it, and deploy. The fastest way to ship.
Wire the two API planes into your own app. Where most teams start.
Extend Hermes or bring any Dockerfile; Agent37 builds the image in the cloud.
Those are the three most common starting points; [Build with Agent37](/docs/agents-api/examples) has the full table of guides and forkable repos. The rest of this page is the API itself: create an instance and stream a reply in two calls.
Mint an API key at [www.agent37.com/dashboard/cloud/api-keys](https://www.agent37.com/dashboard/cloud/api-keys). The full `sk_live_...` key is shown once, at creation. Send it as `Authorization: Bearer` on `api.agent37.com` and as `X-Agent37-Key` on your instance's URL.
New workspaces include enough credit to create one instance. Add funds from [billing](https://www.agent37.com/dashboard/cloud/billing) before you run more than one instance. [Billing](/docs/agents-api/billing) covers pricing, instance limits, and top-up rules.
`POST /v1/instances` provisions a computer running the default `agent37-hermes` template and returns `201` once its `status` is `running`. Every field is optional, but include managed-spend headroom if you want built-in LLM calls to work from the first message.
```bash curl theme={null}
curl -X POST https://api.agent37.com/v1/instances \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "budget": { "credit_micros": 1000000 } }'
```
```python python theme={null}
import requests
H = {"Authorization": "Bearer sk_live_..."}
inst = requests.post(
"https://api.agent37.com/v1/instances",
headers=H,
json={"budget": {"credit_micros": 1_000_000}},
).json()
```
```javascript node theme={null}
const H = { Authorization: "Bearer sk_live_..." };
const inst = await (await fetch("https://api.agent37.com/v1/instances", {
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify({ budget: { credit_micros: 1_000_000 } }),
})).json();
```
```json response theme={null}
{
"id": "ab12cd34ef",
"status": "running",
"status_reason": null,
"template": "agent37-hermes",
"image_ref": "ghcr.io/agent37-platform/hermes:2026.07.02b",
"image_digest": "sha256:4f8d0d9e9f3b3a5a9f4488fb33274de9f7b7450c6cf6d3573e68fd231d7c8891",
"resources": { "cpu": 2, "memory": 4, "disk": 6 },
"url": "https://ab12cd34ef.agent37.app",
"domain_urls": [],
"public_ports": [],
"user": null,
"name": null,
"metadata": null,
"auto_sleep": false,
"idle_timeout_seconds": 300,
"past_due": false,
"created": 1781049600
}
```
The `url` is the instance's own API: the instance id is the DNS label, so `ab12cd34ef` answers at `https://ab12cd34ef.agent37.app`. That is the address you talk to next. Any other port, including the agent's dashboard, terminal, and files, is reachable at `https://ab12cd34ef-{port}.agent37.app`; see [Instance and preview URLs](/docs/agents-api/urls).
`POST /v1/responses` on the instance URL runs a turn. Set `stream: true` to receive Server-Sent Events as the agent reasons, calls tools, and writes its answer. Same `sk_live_` key, sent as the `X-Agent37-Key` header.
```bash curl theme={null}
curl -N 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.",
"stream": true
}'
```
```python python theme={null}
AGENT_H = {"X-Agent37-Key": "sk_live_..."}
r = requests.post(
"https://ab12cd34ef.agent37.app/v1/responses",
headers=AGENT_H,
stream=True,
json={
"input": "Research the top 3 EV makers, write a memo.",
"stream": True,
},
)
for line in r.iter_lines():
print(line.decode())
```
```javascript node theme={null}
const AGENT_H = { "X-Agent37-Key": "sk_live_..." };
const res = await fetch("https://ab12cd34ef.agent37.app/v1/responses", {
method: "POST",
headers: { ...AGENT_H, "Content-Type": "application/json" },
body: JSON.stringify({
input: "Research the top 3 EV makers, write a memo.",
stream: true,
}),
});
// res.body is an SSE stream of named events
```
The stream opens with `response.created`, which carries the ids you need, and ends with a terminal event after which the server closes the connection:
```text events theme={null}
event: response.created
data: {"id":"c91d2a7e84f04b6f9a3d5e1c0b87f4a2","session_id":"7f3e0b6c52a949d2b1c4a8e9d0f31726"}
event: response.output_text.delta
data: {"text":"Here is the memo. Tesla still leads on"}
event: response.completed
data: {"output_text":"Here is the memo...","usage":{"input_tokens":1840,"output_tokens":920,"cost_usd":0.0137}}
```
Prefer not to stream? Leave `stream` off (the default is `false`) and the call returns the finished response as one JSON body.
Reuse the `session_id` to continue the same thread. The agent keeps the full history on the instance, so you send only the new input.
```bash curl theme={null}
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."
}'
```
Done experimenting? `DELETE /v1/instances/{id}` ends billing on the spot: compute is metered per minute, so you only ever pay for the time the instance existed. See [Billing](/docs/agents-api/billing).
Looking for **OpenClaw** channel, model, or networking setup? Those guides live on the [Agent37 blog](https://www.agent37.com/blog).