# Send your first agent message Source: https://www.agent37.com/docs/agents-api/agent-quickstart Use the Agent API included in the Hermes template to start a conversation. Agent templates add a chat API to your sandbox. This guide uses `agent37-hermes`; you can also [host another harness](/docs/agents-api/templates). Your own images only serve the APIs you put in them. ## Create an instance for the agent Get an [API key](https://www.agent37.com/dashboard/cloud/api-keys) and make sure your [workspace wallet](/docs/agents-api/billing) is funded. These examples use curl and `jq`; see the [create endpoint](/docs/agents-api/instances/create) for Python and Node. ```bash theme={null} export AGENT37_API_KEY="sk_live_..." INSTANCE=$(curl --fail-with-body -sS https://api.agent37.com/v1/instances \ -H "Authorization: Bearer $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{"template": "agent37-hermes", "budget": {"credit_micros": 1000000}}') export INSTANCE_ID=$(printf '%s' "$INSTANCE" | jq -er '.id') export INSTANCE_URL=$(printf '%s' "$INSTANCE" | jq -er '.url') ``` The budget permits up to \$1 of managed-service spending from your wallet. It does not add money to the wallet. Without a [budget](/docs/agents-api/budgets), managed model calls are refused. ## Wait for the agent Creation returns when the computer is running. The agent may still be starting. In Bash, poll [health](/docs/agents-api/health) for up to three minutes; `healthy: true` means the harness is ready, while `ok` alone only means the gateway is answering. ```bash theme={null} wait_for_agent() { local deadline=$((SECONDS + 180)) until curl --max-time 10 --fail -sS "$INSTANCE_URL/v1/health" \ -H "X-Agent37-Key: $AGENT37_API_KEY" | jq -e '.healthy == true' > /dev/null; do if (( SECONDS >= deadline )); then echo "Agent is not ready; check instance health and logs." >&2 return 1 fi sleep 2 done } wait_for_agent ``` If the wait fails, check [logs](/docs/agents-api/logs) before continuing. Other harnesses may need their own account or provider key connected before they become healthy; follow the guide for that template. ## Send a message ```bash theme={null} curl --fail-with-body -sS "$INSTANCE_URL/v1/responses" \ -H "X-Agent37-Key: $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{"input": "Say hello in one sentence."}' ``` The finished response includes these fields: ```json Response excerpt theme={null} { "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "status": "completed", "output_text": "Hello!" } ``` Read `status`: a failed turn can return HTTP `200` with `status: "failed"` and an `error`. Reuse `session_id` to continue the conversation, or set `stream: true` for live events. See [Send a message](/docs/agents-api/chat) for the full contract and examples in curl, Python, and Node. ## Clean up or build an app Delete a test instance when you finish; this permanently removes its data and ends billing: ```bash theme={null} curl --fail-with-body -sS -X DELETE "https://api.agent37.com/v1/instances/$INSTANCE_ID" \ -H "Authorization: Bearer $AGENT37_API_KEY" ``` To keep building, follow [Build a chat app](/docs/agents-api/chat-app), [Streaming](/docs/agents-api/streaming), or the [examples](/docs/agents-api/examples). # Host an Android phone Source: https://www.agent37.com/docs/agents-api/android Run an Android 14 emulator with hardware acceleration, ADB, screenshots, persistent phone storage, and a live screen in the browser. The `agent37-android` [template](/docs/agents-api/templates) gives your code an Android 14 emulator with the SDK, ADB, and a live screen in the browser. The image starts the emulator automatically; wait for Android to finish booting before sending it work. This template has no chat API. It only runs as a [VM instance](/docs/agents-api/vm-instances). The emulator needs hardware virtualization (`/dev/kvm`), which a VM has and a sandbox does not, so a create without `"type": "vm"` is a `400`. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt. I want an Android phone I can drive with adb. Create it with POST /v1/instances using the agent37-android template, with "type": "vm" and 4 vCPU / 8 GB / 20 GB disk. Then run `android-wait` through POST /v1/instances/{id}/exec until it reports the boot completed, and show me `adb shell getprop ro.build.version.release`. Run `android-shot` through exec and decode its stdout as a base64 WebP screenshot; check exit_code and truncated on every exec response. Finish by minting a signed URL for port 6901 so I can watch the screen. My key is in AGENT37_API_KEY. ``` ## Create one ```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-android", "type": "vm", "resources": { "cpu": 4, "memory": 8, "disk": 20 } }' ``` Omitting `resources` selects 4 vCPU / 8 GB RAM / 12 GB disk, and 8 GB RAM is the smallest size: a phone reloads its saved state on every start, which a smaller one cannot fit. The example requests 20 GB disk for more storage. Size CPU and RAM against your actual apps and sustained automation workload; a successful boot does not establish that a shape is large enough for every app. A funded workspace is required, as for every VM instance. The create returns as soon as the machine has started. Android is still booting at that point. The AVD is created on first boot; later boots reuse it and can use an emulator quick-boot snapshot when one is available. Wait after every start with the image's own helper, which blocks until Android reports `sys.boot_completed`: ```bash curl theme={null} curl -X POST https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "command": "android-wait 600" }' ``` `android-wait` checks Android boot, not the readiness of a particular app or its first frame. Launch your target app before capturing it, for example with `adb shell am start -W -a android.settings.SETTINGS`. If the display is asleep, send `adb shell input keyevent KEYCODE_WAKEUP` and `adb shell wm dismiss-keyguard` first. ## Drive it with ADB ADB is already connected to the phone, so [exec](/docs/agents-api/exec) is all you need. Nothing has to be exposed to the internet for your code to drive the device. ```bash curl theme={null} curl -X POST https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "command": "adb shell am start -a android.intent.action.VIEW -d https://example.com" }' ``` The commands a device-driving agent leans on: | | | | - | - | | `android-shot` | A base64 WebP screenshot, scaled to 540 pixels wide for the JSON exec response | | `adb exec-out screencap -p` | A full-resolution PNG; transfer binary output over SSH | | `adb shell input tap 540 1200` | A touch; `input text`, `input swipe` and `input keyevent` are the rest of the loop | | `adb shell uiautomator dump` | The view tree, when you would rather act on element bounds than on pixels | | `adb install app.apk` | Sideload an app; `adb shell pm list packages` lists what is installed | | `adb logcat -d -t 200` | The last 200 log lines | | `adb shell dumpsys window \| grep mCurrentFocus` | Which app and activity is in front | [SSH](/docs/agents-api/ssh) works too, and is the better way in when you want `adb pull` of something big or an interactive shell: `ssh root@ab12cd34ef.agent37.app`. ### Screenshots through the API ```bash curl theme={null} curl -X POST https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "command": "android-shot" }' ``` Decode `stdout` from base64 into a WebP image. Check `exit_code: 0` and `truncated: false` before decoding. Exec returns text and caps each output stream at 512 KB, so raw PNG bytes cannot be returned directly in its JSON response. `android-shot 360` requests a smaller frame. ## Watch the screen The screen is served on port `6901` by [noVNC](https://novnc.com), so any browser can open it with no plugin and no client. Mint a [signed URL](/docs/agents-api/urls) and point it at noVNC's own page: ```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": 6901, "ttl_seconds": 300 }' # -> { "url": "https://ab12cd34ef-6901.agent37.app/?a37_token=...", ... } ``` Keep the token and change the path to `/vnc.html`: ``` https://ab12cd34ef-6901.agent37.app/vnc.html?a37_token=...&autoconnect=1&resize=scale ``` Add `&view_only=1` to watch without touching. The URL is a credential: anyone holding it can tap and type on the phone, so mint a short one and hand it only to the device's owner. To embed the screen in your own app rather than open a tab, mount the noVNC client in your page and connect its WebSocket straight to the instance; [Hermes with a desktop](/docs/agents-api/hermes-desktop) has the code, and it applies here unchanged. noVNC is what the template ships because it needs nothing installed and rides the same authenticated edge as every other port. For a smoother picture, run [ws-scrcpy](https://github.com/NetrisTV/ws-scrcpy) on the instance and serve it on its own port: it streams H.264 over a WebSocket and the browser decodes it with WebCodecs. The native [scrcpy](https://github.com/Genymobile/scrcpy) client is better still, over an SSH tunnel to ADB (`agent37 ssh ab12cd34ef -- -L 5037:localhost:5037`). The emulator's own WebRTC stream needs UDP, which the Agent37 edge does not carry. ## What is on the image | | | | - | - | | Android | 14 (API 34), `google_apis` x86\_64, as an AVD named `agent37` on a Pixel 6 profile | | SDK | `platform-tools` (ADB), `emulator`, `cmdline-tools` at `/opt/android-sdk` | | Screen | Xvfb, x11vnc, and noVNC on `6901` | `google_apis` includes Google Play services and supports `adb root`. It does not include the Play Store app. Sideload with `adb install` and test the apps and login flows your product needs. For the Play Store or another Android version, build [your own image](/docs/agents-api/custom-image) from the [Android Dockerfile](https://github.com/agent37-platform/templates/tree/main/images/vm/android), selecting the SDK package with its `SYSTEM_IMAGE` build argument. Changing versions also needs a matching AVD; installing an SDK package alone does not change the existing phone. What the phone stores (apps, logins, files, the quick-boot snapshot) lives in the AVD on the instance's persistent disk, so it survives stop, start, restart, resize and [image updates](/docs/agents-api/instances/update). The SDK itself is in the image and costs nothing against your disk. Environment knobs, as [instance env](/docs/agents-api/instance-environment): `ANDROID_AVD_RAM_MB` (default `2048`), `ANDROID_AVD_DATA_MB` (phone storage, automatically sized from free disk with a 1536 MB floor and 6144 MB cap), `ANDROID_AVD_DEVICE` (default `pixel_6`), `ANDROID_SCREEN_GEOMETRY` (default `560x1000x24`, sized to the window the emulator draws) and `ANDROID_EMULATOR_ARGS` for additional emulator flags. RAM, phone storage, and device profile are set when the AVD is first created; changing these env values does not resize or replace an existing phone. # 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 * **\$5 free to start.** Add a card from the billing page (nothing is charged) and your workspace gets a one-time \$5 credit, enough to run your first instance around the clock for about a month, or a mostly-idle [auto-sleep](/docs/agents-api/instance-auto-sleep) instance for many months. The credit is granted once per account and once per card, and only to a workspace that has not topped up yet; a workspace created by [`POST /v1/signup`](/docs/agents-api/signup) never receives it. Until your first real top-up, the workspace runs one default instance of any template on the 2 vCPU / 4 GB shape; a larger shape, or a Performance or VM instance, returns `403 tier_limit`. Your first top-up unlocks the 4/8, 4/16, and 4/32 shapes, and up to 10 instances running at once; once your top-ups total \$50 the limit rises to 50, at \$250 to 200, and at \$1,000 to 1,000. See [Instance limits](#instance-limits). * **Top up in the dashboard, or from the API.** Add funds at [dashboard/cloud/billing](https://www.agent37.com/dashboard/cloud/billing), or call [`POST /v1/billing/top-up`](#fund-the-wallet-from-the-api). Top-ups are \$5 to \$10,000 each, paid through Stripe. * **Automatic top-up is on by default.** A top-up saves its card and turns on a refill rule (buy the same amount again once the balance drops below half of it) so the wallet refills itself; switch it off as you pay, or adjust it 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. > [!NOTE] > > The wallet is readable and fundable from the API: [`GET /v1/billing`](#read-the-wallet) for the balance and the tier, [`POST /v1/billing/top-up`](#fund-the-wallet-from-the-api) to pay. The ledger and the spend breakdown stay 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 | Running at once | | - | - | | Free (before your first top-up) | 1 instance in total, on the 2 vCPU / 4 GB shape | | Topped up at least once (any amount) | 10, all shapes | | Top-ups total \$50 or more | 50, all shapes | | Top-ups total \$250 or more | 200, all shapes | | Top-ups total \$1,000 or more | 1,000, all shapes | The limit rises on its own: your first top-up lifts the workspace to 10, \$50 in total top-ups makes it 50, \$250 makes it 200, and \$1,000 makes it 1,000, with nothing to request or configure. Top-ups add up over the life of the workspace, and the limit never goes back down. Need more than 1,000 at once? Email [vishnu@agent37.com](mailto:vishnu@agent37.com) or use the chat bubble in the dashboard. Once you have topped up, the limit counts only awake instances. Sleeping and stopped ones never count, so you can keep as many as you like, each billing its disk alone; with one instance per end user and [auto-sleep](/docs/agents-api/instance-auto-sleep), you can serve any number of users as long as no more than your limit are awake at once. A create or [start](/docs/agents-api/instances/start) past the limit returns `409 instance_limit_reached`. A request to a sleeping instance's URL is still served past the limit, and the instance goes back to sleep right after, with `instance_limit_reached` in its `status_reason`. Before your first top-up, the workspace holds one instance in total, sleeping or not. These are limits on how many instances 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 running several. ### Performance accounts For workspaces that need fast starts every time. On a Performance account, new instances start faster and more consistently, and sleeping instances wake faster. It covers every instance in the workspace, on any tier, and is a paid add-on priced per account. To ask for one, click **Request** next to Performance account on the [Billing page](https://www.agent37.com/dashboard/cloud/billing) to chat with us, or email [vishnu@agent37.com](mailto:vishnu@agent37.com). Performance accounts and [Performance instances](#default-and-performance-instances) are separate: the account setting speeds up starts and wakes across the workspace, and a Performance instance runs on cores held for that one instance. ## 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 / 2-12 GB disk (the default) | \$4.76 | | 4 vCPU / 8 GB RAM / 2-20 GB disk | \$9.34 | | 4 vCPU / 16 GB RAM / 2-40 GB disk | \$15.48 | | 4 vCPU / 32 GB RAM / 2-80 GB disk | \$27.76 | Every template offers the same four shapes, 2 vCPU / 4 GB is the smallest, and 4 vCPU is the ceiling on every type. Disk is any whole number of GB within the shape's range and defaults to 4, 6, 12, or 24 GB by shape; every GB bills at \$0.09 per month. See [Instances](/docs/agents-api/instances) for how to pick a shape with `resources` on create. ### Default and Performance instances Every instance is a **default** instance unless you ask for something else, and the prices above are what one costs awake around the clock. For most harnesses the default is all you need: agents spend most of the day idle. A **Performance** instance is a whole machine whose cores are held for it, so it gets the same CPU in a busy hour as in a quiet one. Reach for it when the agent does heavy work, such as computer use (driving a browser or a desktop), and needs the same speed every hour. Isolation and every endpoint on this reference behave identically. Start with the default. For a Performance instance, pass `"type": "performance"` on [create](/docs/agents-api/instances/create); compute is 4x the default rate, and disk is unchanged. It needs a funded workspace, like a [VM instance](/docs/agents-api/vm-instances), and it boots a kernel, so a create takes longer than a default instance's. Default instances share hardware, so sustained heavy CPU use may be limited when the hardware is busy. Performance and VM instances are not. A [VM instance](/docs/agents-api/vm-instances) is a whole virtual machine with its own kernel, for Docker, Kubernetes, databases, and Android emulators. Its compute is 2.5x the default rate, and disk is unchanged. [Auto-sleep](/docs/agents-api/instance-auto-sleep) does not change these rates. An auto-sleep instance bills the same rate as an always-on one for the time it is awake, and disk alone for the time it is asleep, so anything idle for most of the day costs a fraction of the always-on price. See [Per-minute metering](#per-minute-metering). ### Burstable instances `burstable` is an experimental instance type for low average CPU and RAM usage, with pricing starting at \$1/month. [Apply here](https://cal.com/vishnukool/30min) to enable it for your workspace. ## 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/instance-auto-sleep) on or off. | | `sleeping`, `stopped`, or `waking` | The disk rate alone: \$0.09 per reserved GB per month. | | Parked in cold storage | \$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; 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 wrap 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 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/instance-auto-sleep)) releases CPU and RAM and keeps the disk, so parked time bills disk alone. An auto-sleep instance that spends most of its life asleep costs mostly its disk, about \$0.36 per month at the default 4 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 whenever managed usage leaves the balance below the threshold, on an hourly sweep, and once more right before any instance would be suspended, so instances stay up as long as the card charges. * It is on by default when you top up: the **Add funds** dialog sets the purchase amount to what you are adding and the threshold to half of that, at least \$3 (a \$25 top-up buys \$25 again below \$12.50). Switch it off in the same dialog, or adjust both amounts or turn it off later at [dashboard/cloud/billing](https://www.agent37.com/dashboard/cloud/billing). It charges your default card, or the most recently saved one when none is set. * The threshold can be \$3 to \$10,000. The purchase amount follows the usual \$5 to \$10,000 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. **past\_due** `boolean` `true` while the instance is suspended for non-payment. Cleared by topping up. ```json wrap 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`. An instance left suspended for 30 days is permanently deleted. > [!TIP] > > 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. ## Wallet endpoints Two endpoints cover the whole money rail from a program: read what is left, and pay. Both take the workspace key. | Method | Path | Returns | | - | - | - | | `GET` | `/v1/billing` | `200` with the balance, tier, instance ceiling, refill rule, and suspension state | | `POST` | `/v1/billing/top-up` | `201` with a Checkout URL | ### Read the wallet `GET /v1/billing` answers what is left. It is deliberately separate from [`GET /v1/usage`](#read-workspace-usage), which reports what was spent and never what remains. **balance\_usd** `number` What the wallet holds, in dollars. Negative while a workspace is past due. **tier** `integer` `0` before the first top-up, then `1`, `2`, `3`, or `4` as total top-ups pass the thresholds in [Instance limits](#instance-limits). **instance\_limit** `integer` How many instances the workspace may run at once: `10`, `50`, `200`, or `1000` by tier, unless a higher limit was assigned. `1` on the free tier, counting every instance it holds. **auto\_top\_up** `object` The refill rule, or `null` until a top-up arms one: `enabled`, `threshold_usd`, and `amount_usd`. See [Automatic top-up](#automatic-top-up). **past\_due** `boolean` `true` when non-payment has suspended the workspace's instances. A top-up clears it and they become startable again. See [Past due and suspension](#past-due-and-suspension). ```bash curl wrap theme={null} curl https://api.agent37.com/v1/billing \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.get( "https://api.agent37.com/v1/billing", headers={"Authorization": "Bearer sk_live_..."}, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch("https://api.agent37.com/v1/billing", { headers: { Authorization: "Bearer sk_live_..." }, }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```json theme={null} { "balance_usd": 42.5, "tier": 1, "instance_limit": 10, "auto_top_up": { "enabled": true, "threshold_usd": 12.5, "amount_usd": 25 }, "past_due": false } ``` ### Fund the wallet from the API `POST /v1/billing/top-up` answers with a Stripe-hosted payment page for `amount_usd`. **amount\_usd** `number` *required* How much to add, \$5 to \$10,000. **url** `string` A hosted Stripe Checkout link for `amount_usd`. **amount\_usd** `number` The amount the link charges. **paid** `boolean` `false`: nothing has moved when the call returns, and the money lands when the payment confirms. Open it, pay once, and the card is saved: [automatic top-up](#automatic-top-up) then refills the wallet off-session from then on, so the browser is needed exactly once per account and never again. The balance changes when Stripe confirms the payment, so poll `GET /v1/billing` for it. ```bash curl wrap theme={null} curl -X POST \ https://api.agent37.com/v1/billing/top-up \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"amount_usd": 25}' ``` ```python Python wrap theme={null} import requests response = requests.post( "https://api.agent37.com/v1/billing/top-up", headers={"Authorization": "Bearer sk_live_..."}, json={"amount_usd": 25}, ) response.raise_for_status() print("Open to pay:", response.json()["url"]) ``` ```javascript Node wrap theme={null} const response = await fetch("https://api.agent37.com/v1/billing/top-up", { method: "POST", headers: { Authorization: "Bearer sk_live_...", "Content-Type": "application/json", }, body: JSON.stringify({ amount_usd: 25 }), }); if (!response.ok) throw new Error(await response.text()); console.log("Open to pay:", (await response.json()).url); ``` ```json theme={null} { "url": "https://checkout.stripe.com/c/pay/cs_live_...", "amount_usd": 25, "paid": false } ``` ## 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. Review the ledger, your balance, and the spend breakdown at [dashboard/cloud/billing](https://www.agent37.com/dashboard/cloud/billing); the [usage page](https://www.agent37.com/dashboard/cloud/usage) shows the same numbers by day, by instance, and by model for any window in the last 90 days. ### Read workspace usage `GET /v1/usage?from=YYYY-MM-DD&to=YYYY-MM-DD` returns everything the workspace spent, by UTC day, by instance, and by model, for a window of up to 90 days. Both parameters are optional: `to` defaults to today and `from` to 30 days ending there. Deleted instances stay in the list for the days they spent. Compute lands here within the hour (it is metered per minute and settled hourly); models, search, and tools land as each call is billed. **from** `string` First UTC day covered, formatted `YYYY-MM-DD`. **to** `string` Last UTC day covered, inclusive. **total\_micros** `integer` Everything spent in the window, in USD millionths. **by\_integration** `object` `compute`, `llm`, `brave`, `composio`, and `perflo`, each with `cost_micros`. `llm` also carries `calls`, `input_tokens`, and `output_tokens`. `perflo` is [paid tools](/docs/agents-api/managed-services#paid-tools). **by\_model** `array` One entry per model called in the window, highest spend first: `model` (the id as the provider reports it), `cost_micros`, `calls`, `input_tokens`, `output_tokens`. Calls whose model was not recorded are grouped under `unknown`. **days** `array` One entry per UTC day in the window, oldest first: `date`, `total_micros`, `compute_micros`, `llm_micros`, `brave_micros`, `composio_micros`, `perflo_micros`, `llm_calls`, `input_tokens`, `output_tokens`. Days with no spend are present with zeros. **instances** `array` One entry per instance that spent anything in the window, highest total first, with the same fields as a day plus the instance `id`. ```bash curl wrap theme={null} curl -X GET \ https://api.agent37.com/v1/usage \ --url-query "from=2026-08-11" \ --url-query "to=2026-09-09" \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.get( "https://api.agent37.com/v1/usage", headers={ "Authorization": "Bearer sk_live_..." }, params={ "from": "2026-08-11", "to": "2026-09-09" }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const query = new URLSearchParams({ "from": "2026-08-11", "to": "2026-09-09" }); const response = await fetch( "https://api.agent37.com/v1/usage" + "?" + query, { method: "GET", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json response wrap theme={null} { "from": "2026-08-11", "to": "2026-09-09", "total_micros": 4898130000, "by_integration": { "compute": { "cost_micros": 2100000000 }, "llm": { "cost_micros": 2588130000, "calls": 410212, "input_tokens": 23400000000, "output_tokens": 610000000 }, "brave": { "cost_micros": 110000000 }, "composio": { "cost_micros": 60000000 }, "perflo": { "cost_micros": 40000000 } }, "by_model": [ { "model": "openai/gpt-5.6-luna", "cost_micros": 2401000000, "calls": 380100, "input_tokens": 21900000000, "output_tokens": 570000000 }, { "model": "deepseek/deepseek-v4-flash", "cost_micros": 187130000, "calls": 30112, "input_tokens": 1500000000, "output_tokens": 40000000 } ], "days": [ { "date": "2026-08-11", "total_micros": 152400000, "compute_micros": 70000000, "llm_micros": 76200000, "brave_micros": 3500000, "composio_micros": 1500000, "perflo_micros": 1200000, "llm_calls": 12840, "input_tokens": 720000000, "output_tokens": 19000000 } ], "instances": [ { "id": "ab12cd34ef", "total_micros": 312400000, "compute_micros": 21000000, "llm_micros": 291400000, "brave_micros": 0, "composio_micros": 0, "perflo_micros": 0, "llm_calls": 9120, "input_tokens": 2100000000, "output_tokens": 48000000 } ] } ``` For managed spend on a single instance for one month, `GET /v1/instances/{id}/usage` returns a monthly rollup; see [Budgets](/docs/agents-api/budgets#read-managed-spend). # Browser Use Source: https://www.agent37.com/docs/agents-api/browser-use Run your Hermes agents' browser on Browser Use Cloud, with stealth, residential proxies, and CAPTCHA solving: one environment variable for your own agent, or a small broker that gives every user's agent a browser without exposing your Browser Use key. `agent37-hermes` browses the web out of the box with a Chromium inside the instance. To run that browser on [Browser Use Cloud](https://browser-use.com) instead, with its [stealth](https://docs.browser-use.com/cloud/browser/stealth) and [residential proxies](https://docs.browser-use.com/cloud/browser/proxies), pick the setup that matches who chats with the agent: | | Your own agent | An agent per user | | - | - | - | | Who chats with it | You or your team | Your users | | What the instance holds | Your `BROWSER_USE_API_KEY` | A per-user link to your server | | Setup | Two `env` values | The [hermes-browser-use](https://github.com/agent37-platform/examples/tree/main/hermes-browser-use) broker on your server, plus one `env` value | Anything in an instance's environment, the agent can read and repeat back. When your users chat with their agents, a key in the environment is a key your users can take, so keep it on your server. ## An agent per user ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt. I want each of my users' Hermes agents to browse on Browser Use Cloud without my Browser Use key in their instance. Run https://github.com/agent37-platform/examples/tree/main/hermes-browser-use behind a cloudflared tunnel as its README says, then create an agent for a test user with npm run create-agent. Done when that agent, asked to open https://ipinfo.io/json with its browser tool, reports a consumer internet provider as the org. My keys are in AGENT37_API_KEY and BROWSER_USE_API_KEY. ``` > [card] [agent37-platform/examples: hermes-browser-use](https://github.com/agent37-platform/examples/tree/main/hermes-browser-use) > > A broker server and a create script, plain Node with no dependencies. Your Browser Use key stays on the server; each user's agent gets a link that opens only that user's browser. Hermes reads `BROWSER_CDP_URL` the way it reads a Chrome debugging port: it asks that URL's `/json/version` for a browser and drives whatever comes back. Point it at your server with a token for the user: ```bash 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", "env": { "BROWSER_CDP_URL": "https://your-server.com/browser/" }, "budget": { "monthly_cap_micros": 2000000 } }' ``` Your server checks the token, starts that user's Browser Use browser with your key (or reuses the one already running), and returns the browser's address. The agent then drives the browser directly; no page traffic passes through your server. * **One browser per user.** It is labelled with the user in your Browser Use dashboard and reused across that user's chats until it times out (15 minutes by default), so a sign-in carries over between chats while it lives. * **A copied link opens one browser.** A user who takes their link can only reach their own browser, and you cut them off in the token check. * **Concurrency.** Each user holds at most one browser, so your Browser Use plan's concurrent session limit caps how many users browse in the same window. * **If your server is down,** browsing fails for that turn and the rest of the agent keeps working. ## Your own agent When you are the one chatting with the agent, put your Browser Use key straight into its environment, with `TMPDIR` set to `/tmp`: ```bash 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", "env": { "BROWSER_USE_API_KEY": "bu_...", "TMPDIR": "/tmp" }, "budget": { "monthly_cap_micros": 5000000 } }' ``` Hermes picks the key up by itself. `TMPDIR` works around a Hermes bug that makes Browser Use sessions fail when Hermes keeps its temp files under the home directory ([hermes-agent#122786](https://github.com/NousResearch/hermes-agent/issues/122786)); without it the browser tool fails and the agent falls back to fetching pages without a browser. * **One browser per turn.** Each chat turn that browses opens its own cloud browser, and Hermes closes it when the turn ends. A sign-in does not carry over to the next turn. * **Fallback to the instance.** If Browser Use won't start a browser, for example because the account is out of credits or at its concurrency limit, Hermes loads the page in the instance's own Chromium and the turn still completes. To add either setup to an existing instance, [edit its environment](/docs/agents-api/instances/edit) and [restart](/docs/agents-api/instances/restart) it. Waking a [sleeping](/docs/agents-api/instance-auto-sleep) instance keeps the old environment. ## Check it works Ask the agent to open an IP lookup page with its browser tool: ```bash theme={null} curl https://ab12cd34ef.agent37.app/v1/responses \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "input": "Open https://ipinfo.io/json with your browser tool and tell me the ip and org fields." }' ``` An `org` that is a consumer internet provider (Charter, Comcast, and the like) means the page loaded through Browser Use's residential proxy. A cloud or hosting company means it did not: either the browser ran inside the instance, or the agent fetched the page without a browser. ## Browser Use Cloud or the built-in browser | | Built-in Chromium | Browser Use Cloud | | - | - | - | | Setup | None | See above | | Cost | Included in the instance | Billed by Browser Use ([pricing](https://browser-use.com/pricing)) | | Visits from | The instance's datacenter IP | Residential proxies | | Bot checks and CAPTCHAs | Often blocked | Stealth browser and CAPTCHA solving | Use the built-in browser for sites that don't fight automation, and Browser Use Cloud when the sites your agents visit block datacenter traffic. To watch the built-in browser work and take over a sign-in, see [Browser and desktop](/docs/agents-api/desktop). # Managed services & budgets Source: https://www.agent37.com/docs/agents-api/budgets Control each instance's spending on the LLM router, Brave Search, Composio, and Perflo through one budget and workspace wallet. Every instance is created with managed credentials for four services: the LLM router, web search (Brave), app integrations (Composio), and paid tools (Perflo). Managed calls route and meter through Agent37, so there are no provider or integration keys for you to manage. See [Managed services](/docs/agents-api/managed-services) for HTTP and MCP examples, including custom-image setup. Each paid 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 | OpenRouter inference cost, passed through with no Agent37 markup | | Web search (Brave) | \$0.005 per call (5,000 micros) | | Composio | \$0.000114 per call (114 micros) | | Perflo paid tools | Reported charge, passed through with no markup; discovery is free | 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. The instance's **starter token**, `AGENT37_MANAGED_TOKEN`, authorizes managed-service calls that spend wallet credit within this budget. Your `sk_live_...` workspace key manages the budget itself. [Managed services](/docs/agents-api/managed-services#spend-workspace-credit-with-the-starter-token) shows the setup. 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 its supported paid, tool-capable OpenRouter models at `GET https://api.agent37.com/llm/v1/models`. 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: OpenRouter inference cost passed through with no Agent37 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. > [!NOTE] > > 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. **budget.monthly\_cap\_micros** `integer` (default: 0) Monthly managed-spend cap in micros. Resets each UTC month. **budget.credit\_micros** `integer` (default: 0) One-time headroom in micros. Persists until consumed. ```bash curl wrap 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", "name": "research-bot", "budget": { "monthly_cap_micros": 5000000, "credit_micros": 1000000 } }' ``` ```python python wrap 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={ "template": "agent37-hermes", "name": "research-bot", "budget": {"monthly_cap_micros": 5000000, "credit_micros": 1000000}, }, ).json() ``` ```javascript node wrap 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({ template: "agent37-hermes", 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 **monthly\_cap\_micros** `integer` The monthly spending ceiling. **monthly\_consumed\_micros** `integer` Managed spend counted against the cap this month. **monthly\_remaining\_micros** `integer` `monthly_cap_micros` minus `monthly_consumed_micros`, floored at 0. **monthly\_period** `string` The UTC month the counters cover, formatted `YYYY-MM`. **credit\_remaining\_micros** `integer` One-time headroom left. Consumed only after the monthly portion is exhausted. **updated\_at** `integer` 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 wrap theme={null} curl -X GET \ https://api.agent37.com/v1/instances/ab12cd34ef/budget \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.get( "https://api.agent37.com/v1/instances/ab12cd34ef/budget", headers={ "Authorization": "Bearer sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/budget", { method: "GET", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json response wrap 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. **monthly\_cap\_micros** `integer` *required* The new monthly cap in micros. Must be a non-negative integer. ```bash curl wrap 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 wrap theme={null} import requests response = requests.patch( "https://api.agent37.com/v1/instances/ab12cd34ef/budget", headers={ "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }, json={ "monthly_cap_micros": 20000000 }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/budget", { method: "PATCH", headers: { "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }, body: JSON.stringify({ "monthly_cap_micros": 20000000 }), }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.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. **amount\_micros** `integer` *required* Headroom to add, in micros. Must be a positive integer. **idempotency\_key** `string` 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 wrap 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": "monthly-burst-1" }' ``` ```python Python wrap theme={null} import requests response = requests.post( "https://api.agent37.com/v1/instances/ab12cd34ef/budget/top-up", headers={ "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }, json={ "amount_micros": 10000000, "idempotency_key": "monthly-burst-1" }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/budget/top-up", { method: "POST", headers: { "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }, body: JSON.stringify({ "amount_micros": 10000000, "idempotency_key": "monthly-burst-1" }), }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.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. **period** `string` The UTC month covered, formatted `YYYY-MM`. **total\_micros** `integer` Total managed spend for the month. **by\_integration** `object` Per-service breakdown. `llm` carries `cost_micros`, `calls`, `input_tokens`, and `output_tokens`; `brave`, `composio`, and `perflo` carry `cost_micros` and `calls`. `perflo` is [paid tools](/docs/agents-api/managed-services#paid-tools). ```bash curl wrap theme={null} curl -X GET \ https://api.agent37.com/v1/instances/ab12cd34ef/usage \ --url-query "month=2026-06" \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.get( "https://api.agent37.com/v1/instances/ab12cd34ef/usage", headers={ "Authorization": "Bearer sk_live_..." }, params={ "month": "2026-06" }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const query = new URLSearchParams({ "month": "2026-06" }); const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/usage" + "?" + query, { method: "GET", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json response wrap 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 }, "perflo": { "cost_micros": 0, "calls": 0 } } } ``` > [!NOTE] > > This endpoint covers managed spend on one instance. For everything the workspace spent, compute included, by day and by instance, use `GET /v1/usage`. See [Billing](/docs/agents-api/billing#read-workspace-usage). ## 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 Sandbox 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 Sandbox 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. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt. I want my agents to run on my own OpenRouter key instead of the managed model: my models, a revocable token per agent, my own spend caps. Clone https://github.com/agent37-platform/hermes-byo-model and follow the guide's Get started steps; setup needs my OPENROUTER_API_KEY and a Supabase access token (ask me for both), and npm run dev needs the cloudflared binary installed. Done when the agent answers a chat message through my OpenRouter key and its Settings tab shows the cost. My key is in AGENT37_API_KEY. ``` > [card] [agent37-platform/hermes-byo-model](https://github.com/agent37-platform/hermes-byo-model) > > 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. > [!NOTE] > > 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 **1. Get your keys** 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. **2. Clone it** ```bash theme={null} git clone https://github.com/agent37-platform/hermes-byo-model cd hermes-byo-model ``` **3. Run setup** 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`. **4. Run it** ```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. > [!NOTE] > > 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 POST https://{instanceId}.agent37.app/v1/responses Run a turn through an agent template, stream or return its result, and continue the conversation. Send a message or task to the agent on your instance. Omit `session_id` to start a conversation. ```bash curl wrap theme={null} curl -X POST \ 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/node/leads.csv" ], "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "stream": false, "model": "default", "provider": "custom:Agent37", "reasoning_effort": "medium", "metadata": { "user": "u_882" }, "agent": "hermes", "profile": "default", "mode": "chat" }' ``` ```python Python wrap theme={null} import requests response = requests.post( "https://ab12cd34ef.agent37.app/v1/responses", headers={ "X-Agent37-Key": "sk_live_...", "Content-Type": "application/json" }, json={ "input": "Summarize the attached spreadsheet.", "files": [ "/home/node/leads.csv" ], "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "stream": False, "model": "default", "provider": "custom:Agent37", "reasoning_effort": "medium", "metadata": { "user": "u_882" }, "agent": "hermes", "profile": "default", "mode": "chat" }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://ab12cd34ef.agent37.app/v1/responses", { method: "POST", headers: { "X-Agent37-Key": "sk_live_...", "Content-Type": "application/json" }, body: JSON.stringify({ "input": "Summarize the attached spreadsheet.", "files": [ "/home/node/leads.csv" ], "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "stream": false, "model": "default", "provider": "custom:Agent37", "reasoning_effort": "medium", "metadata": { "user": "u_882" }, "agent": "hermes", "profile": "default", "mode": "chat" }), }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json 200 (excerpt) wrap theme={null} { "id": "c91d2a7e84f04b6f9a3d5e1c0b87f4a2", "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "status": "completed", "output_text": "The spreadsheet lists your leads." } ``` `POST /v1/responses` runs a turn on templates that include the Agent API. You make it against your instance, not against `api.agent37.com`: agent templates serve their 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 Sandbox 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 The example shows all request fields for Hermes. [Upload `leads.csv`](/docs/agents-api/files#write-a-file) first, use a session id from a previous response to continue a conversation, and choose `model` and `provider` from [Models](/docs/agents-api/models). Omit optional fields you do not need. Request bodies are capped at 2 MB; anything larger returns `413 payload_too_large`. **input** `string` *required* 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. **files** `string[]` 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. **session\_id** `string` 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. The exception is a harness that issues its own session ids: `codex`, `grok`, `opencode` and `pi` reject a first turn on an id they did not issue with `400 validation_error` (`param: session_id`), so on those four always start a thread by omitting `session_id` and reuse the id the response returns. **stream** `boolean` (default: false) `true` returns a Server-Sent Events stream; `false` returns the finished response as one JSON body. See [Streaming](/docs/agents-api/streaming). **model** `string` The LLM to run this turn on, an id from `GET /v1/models` (on OpenClaw the ids are `provider/model`). On Hermes it applies to this turn only: omit it and the turn runs on the instance's default model, whatever earlier turns chose. On OpenClaw a named model is written to the session and sticks for the turns that follow; a turn that omits it leaves the session's model as is. A `model` OpenClaw does not allow (an id outside `GET /v1/models`) never starts the turn: the response settles with `status: "failed"` and `error.code` `agent_error`, with OpenClaw's reason in `message`, and the session keeps its previous model. See [Models](/docs/agents-api/models). **provider** `string` The model's provider, for example `anthropic`. Hermes only, and per turn like `model`: it tells Hermes which configured provider to run the named model on. OpenClaw ignores it (the response still echoes it); there the provider is the prefix of the `provider/model` id you pass as `model`. **reasoning\_effort** `string` How hard the model thinks: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`. `max` is the strongest plain thinking level; `ultra` is the harness's own Ultra mode, top thinking plus its agentic orchestration (on OpenClaw it also turns on proactive subagent orchestration). It applies to this turn only on both harnesses. Omit it and Hermes uses the instance's configured effort, while OpenClaw uses the session's own thinking level. On OpenClaw the values map one to one onto its thinking levels, with `none` sent as `off`. `max` and `ultra` need gateway `0.10.0`: older instances reject them with `validation_error` until [updated](/docs/agents-api/instances/update). Codex and OpenCode advertise their levels per model, and a level a model does not accept is run at the nearest level below it rather than failing the turn. The response's `reasoning_effort` reports the level applied, so read that rather than assuming the request was honoured. **metadata** `object` Up to 16 key/value pairs, at most 64 KB serialized. Echoed back on the response object, never interpreted. **agent** `string` Which agent harness runs the turn, `hermes`, `openclaw`, `claude-code`, `codex`, `grok`, `opencode`, or `pi`. Omit it to use the instance's configured default (`hermes` on `agent37-hermes`, `openclaw` on `agent37-openclaw`, `claude-code` on `agent37-claude-code`, `codex` on `agent37-codex`, `grok` on `agent37-grok`, `opencode` on `agent37-opencode`, `pi` on `agent37-pi`). 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 does not reject the call: the turn settles with `status: "failed"` and `error.code` `agent_unavailable`. **profile** `string` A named setup inside the agent, with its own instructions, skills, memory, config, and sessions, so one instance can host several distinct agents (a support agent and a sales agent, say). Omit it, or send `default`, for the agent's own setup. Like `agent`, it is per request: send it on every turn of the session, and as `?profile=` on the [session reads](/docs/agents-api/sessions#profiles). Supported on `hermes` today; `profile` with any other `agent` is `400 validation_error`. A name that is not lowercase letters, digits, `-` or `_` is `400 validation_error`, and a profile that does not exist on the instance is `404 profile_not_found`. **mode** `string` (default: chat) `chat` runs one turn and replies. `goal` is reserved: sending it returns `400 validation_error` today. > [!NOTE] > > `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 example shows selected fields. The complete response fields are listed below. The response object. Ids are 32-character hex strings; timestamps from the gateway are epoch milliseconds. **id** `string` The response id. Use it to reconnect or cancel the turn. **session\_id** `string` The conversation this turn belongs to. Reuse it on the next call to continue the thread. **status** `string` `in_progress`, then a terminal `completed`, `failed`, or `cancelled`. **agent** `string` The agent that ran the turn, `hermes`, `openclaw`, `claude-code`, `codex`, `grok`, `opencode`, or `pi`. **profile** `string | null` The profile the turn ran in, or `null` for the agent's own setup. **model** `string | null` The `model` you sent on this request, echoed back. `null` when you omitted it: the turn still ran on the harness's default or session model, but the gateway does not report which model the harness resolved. **provider** `string | null` The `provider` you sent on this request, echoed back; `null` when you omitted it. **reasoning\_effort** `string | null` The reasoning level applied to this turn, which is not always the one you asked for. Every harness takes the same eight levels and maps them onto its own: Hermes and OpenClaw run all eight, Claude Code runs `minimal` as `low`, grok and pi run `ultra` as `max`, and Codex and OpenCode go further and advertise which levels each individual model supports. Rather than reject a turn for a level a model cannot run, the gateway runs it at the nearest level below. Ask OpenCode for `ultra` and you get `max` or lower back. `null` when you sent no level and the harness used its own default: the turn still ran at some effort, but the gateway reports only what it applied. This is the level sent to the harness, not a reading taken from the model. A harness that lowers it again on its own is not visible here: Pi clamps to the model's limits itself, and a non-reasoning Grok model ignores the setting outright. Read it as the ceiling the turn ran under. It also rides the terminal `response.completed` event when you [stream](/docs/agents-api/streaming). Needs gateway `0.18.0`; older instances omit the field. **output\_text** `string` The agent's final answer. Always a string, empty if the turn produced none. **usage** `object | null` 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. **context** `object | null` The session's context window as of this turn: `{ used_tokens, window_tokens }`, the tokens occupying the model's window against the window's size. `null` when the harness didn't report a measurement; a cancelled turn reports none on either harness. Hermes reports it on every completed turn. OpenClaw reports none, so the gateway derives it from the turn: `used_tokens` is the turn's token total (input, output, and cache tokens) and `window_tokens` is the model's context window from OpenClaw's model catalog, or `128000` (OpenClaw's own default budget) when the catalog lists no window for the model. On OpenClaw it is `null` when the turn reported no usage, no model, a zero token total, or a model that is not in the catalog. **error** `object | null` Set when the turn failed: `{ code, message, param?, hint? }`. See [Errors](/docs/agents-api/errors). **metadata** `object | null` Your request metadata, echoed back verbatim. **created** `number` When the turn started, epoch milliseconds. > [!NOTE] > > 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. > [!NOTE] > > 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. There is no time limit on a turn; the connection stays open until the agent is done. 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. On a sleeping instance no headers arrive at all until the wake completes, so give the request a first-byte timeout of at least three minutes; past the edge's three-minute wake budget the request gets `503 wake_timeout` instead. > [!TIP] > > 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`, `usage`, and `context`. See [Streaming](/docs/agents-api/streaming). ## Complete response example ```json wrap theme={null} { "id": "c91d2a7e84f04b6f9a3d5e1c0b87f4a2", "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "status": "completed", "agent": "hermes", "model": null, "provider": null, "reasoning_effort": "high", "output_text": "Hello!", "usage": { "input_tokens": 1840, "output_tokens": 920, "cost_usd": 0.0137 }, "context": { "used_tokens": 22600, "window_tokens": 256000 }, "error": null, "metadata": null, "created": 1781136000000 } ``` ## 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 wrap 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 wrap 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 wrap 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.", }), }); ``` > [!NOTE] > > **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). ### When the agent needs a decision from you The agent asks by ending its turn: the response comes back `completed` with the question (and the options, if any) as `output_text`, and the turn is over. Answer by sending the reply as the next `input` on the same `session_id`. There is no in-turn prompt to answer while a response is `in_progress`, so a chat client needs nothing special: render the question like any other reply, and send the user's answer like any other message. ### When the agent delegates to subagents On Hermes, subagents run inside the turn: the response stays `in_progress` until they finish, and the agent's final answer, their results included, arrives in that same response. A delegation can run for minutes with nothing on the stream but `:keepalive` comments between the `delegate_task` tool call's `started` and `completed` events, so keep the connection open or [reattach](#follow-up-on-a-response). Cancelling the turn stops the subagents too. This needs gateway `0.15.1`: older instances end the turn as soon as the subagents are dispatched and hand their results to the agent at the start of your next message, until [updated](/docs/agents-api/instances/update). ## 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 (on OpenClaw it aborts the run inside OpenClaw). It returns 200 with the current response object as soon as the stop is requested, not once the agent has actually stopped, so the body normally still reads `status: "in_progress"`; the response settles to `cancelled` when the turn unwinds. To see it end, read `GET /v1/responses/{id}/stream`: a cancelled turn still closes with `response.completed`, carrying the `output_text` accumulated so far. Cancelling a finished response is a no-op that returns its terminal state, still 200. ```bash curl wrap theme={null} curl -X POST \ https://ab12cd34ef.agent37.app/v1/responses/c91d2a7e84f04b6f9a3d5e1c0b87f4a2/cancel \ -H "X-Agent37-Key: sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.post( "https://ab12cd34ef.agent37.app/v1/responses/c91d2a7e84f04b6f9a3d5e1c0b87f4a2/cancel", headers={ "X-Agent37-Key": "sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://ab12cd34ef.agent37.app/v1/responses/c91d2a7e84f04b6f9a3d5e1c0b87f4a2/cancel", { method: "POST", headers: { "X-Agent37-Key": "sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` > [!WARNING] > > 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`, or (OpenClaw only) the input was one of OpenClaw's own stop commands such as `stop` or `exit`, so no run started. | # 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. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt. I want a chat UI on one always-on agent, with replies streamed token by token. Create one instance with POST /v1/instances (set template to agent37-hermes and budget.credit_micros so it can answer from the first message), send turns to https://{instanceId}.agent37.app/v1/responses with the X-Agent37-Key header and stream: true, and pass the session_id from the first reply back on every later turn. Done when response.output_text.delta events render in the UI as they arrive. My key is in AGENT37_API_KEY. ``` > [card] [hermes-chat: this guide as a working app](https://github.com/agent37-platform/examples/tree/main/hermes-chat) > > 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 > [!NOTE] > > 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 **1. One instance per user, on signup** 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 '{ "template": "agent37-hermes", "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({ template: "agent37-hermes", 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 ``` `"template": "agent37-hermes"` gives you the Hermes agent on the default 2 vCPU / 4 GB RAM / 4 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 `"healthy": true`; `ok` alone only means the gateway is up (see [Health & version](/docs/agents-api/health)). Store `inst.id` on the user row. **2. A session per chat thread** 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); ``` > [!TIP] > > 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. **3. List threads for the sidebar** `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(); // every harness: [{ id, title, last_active, message_count, preview }] // fields the harness does not track are null; timestamps are epoch milliseconds ``` Every row carries a `title`: Hermes fills one in on its own after the first exchange, while OpenClaw leaves it `null` until you set one. Set or replace it on either harness with `PATCH /v1/sessions/{id}` (`{ "title": "..." }`) when the user renames a thread. A title already used by another session returns `409 title_conflict`. **4. Load a thread when it opens** `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 // session.context: { used_tokens, window_tokens }, or null until a turn reports one ``` 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 returns 200 as soon as the stop is requested, so its body normally still reads `status: "in_progress"`; the response settles to `status: "cancelled"` when the turn unwinds and the stream closes with `response.completed`. 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 (an instance still on 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. # Host Claude Code Source: https://www.agent37.com/docs/agents-api/claude-code Run Anthropic's Claude Code on its own always-on instance, signed in to your own Anthropic account. The `agent37-claude-code` [template](/docs/agents-api/templates) runs Claude Code, Anthropic's coding agent, on an Agent37 instance: an always-on computer with the same chat API, sessions, files, and URLs as every other template. The image is lean, with no browser and no desktop. Claude Code runs on **your own Anthropic account**: Agent37 never supplies or bills Claude model usage, and there is no managed-model option on this template, so connect your account before the first chat turn. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt. I want Claude Code on its own always-on instance, signed in to my own Anthropic account. Create it with POST /v1/instances using the agent37-claude-code template; ask me for the token from claude setup-token and pass it as CLAUDE_CODE_OAUTH_TOKEN in env. Done when a message to https://{instanceId}.agent37.app/v1/responses returns a reply from Claude Code. My key is in AGENT37_API_KEY. ``` ## Create the instance One call to the Sandbox API at `https://api.agent37.com`: ```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-claude-code" }' ``` The `201` response carries the instance id and its URL, `https://{instanceId}.agent37.app`, which routes to the gateway on port `3737`. The terminal is on port `7681` and the file browser on `8080`, each at `https://{instanceId}-{port}.agent37.app`; see [Instance and preview URLs](/docs/agents-api/urls). ## Connect your Claude account Three ways to connect. Until one of them is done, a chat turn fails with `auth_error`, and `GET /v1/health?agent=claude-code` on the instance URL reports `"healthy": false`. **Log in from the instance terminal.** Mint a [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls) for the terminal port: ```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": 7681 }' ``` Open the returned `url` in a browser and run `claude auth login` in the shell. The login persists on the instance volume, so it survives restarts and updates. **Set `CLAUDE_CODE_OAUTH_TOKEN`.** The way to go when your app provisions instances headlessly. Generate the token on your own machine with `claude setup-token`, then pass it as [instance env](/docs/agents-api/instance-environment) on the create call: ```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-claude-code", "env": { "CLAUDE_CODE_OAUTH_TOKEN": "..." } }' ``` **Set `ANTHROPIC_API_KEY`.** Same shape: pass an Anthropic API key as `env` on create instead. ## Send the first message Chat is the instance URL plus a path, with the same `sk_live_` key sent as the `X-Agent37-Key` header. `claude-code` is the template's default agent, so there is no `agent` field to pass: ```bash curl theme={null} curl https://ab12cd34ef.agent37.app/v1/responses \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "input": "Clone github.com/expressjs/express and summarize how routing works." }' ``` Set `"stream": true` to receive the turn as Server-Sent Events instead of one JSON body, and reuse the returned `session_id` to continue the thread. See [Send a message](/docs/agents-api/chat) and [Streaming](/docs/agents-api/streaming). ## Models `GET /v1/models` on the instance lists five aliases: `default`, `fable`, `opus`, `sonnet`, and `haiku`, all with provider `anthropic`. Pass one as `model` on a turn. Usage bills to your Anthropic account, not your Agent37 wallet. See [Models](/docs/agents-api/models). ## Bring your own model Claude Code can run on a different model provider entirely: point it at any Anthropic-compatible Messages endpoint with two more `env` entries on the create call. With OpenRouter: ```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-claude-code", "env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", "ANTHROPIC_AUTH_TOKEN": "" } }' ``` No Anthropic account is needed: model usage bills the key behind `ANTHROPIC_AUTH_TOKEN`, and health reports healthy once the instance is up. To pin what each [model alias](#models) runs, add role variables as provider slugs: `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, and `ANTHROPIC_DEFAULT_FABLE_MODEL` (for example `"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-5"`, so a turn with `"model": "opus"` runs that slug). Claude Code is built for Anthropic models; pointing a role at another family can mishandle the tool calls it edits files with. Runnable version: [claude-code-byo-model](https://github.com/agent37-platform/examples/tree/main/claude-code-byo-model) in the examples repo. ## Connect apps [App integrations](/docs/agents-api/integrations) work here like on every system template: drive the `/v1/integrations` endpoints to connect your users' Gmail, Slack, Notion, and hundreds of other apps, and the platform registers its Composio MCP server with Claude Code automatically. ## Custom image To add your own tools, build on the clean base image, which ships Claude Code and the gateway with no managed integrations, the same pattern as `hermes-base`: ```dockerfile theme={null} FROM ghcr.io/agent37-platform/claude-code-base:latest ``` See [Build a custom image](/docs/agents-api/custom-image). ## Image source The image behind `agent37-claude-code` is built from a public Dockerfile, [`images/b2b/claude-code`](https://github.com/agent37-platform/templates/tree/main/images/b2b/claude-code) in [agent37-platform/templates](https://github.com/agent37-platform/templates). Read it, or point your coding agent at it, to see exactly what the instance has installed and how the entrypoint configures Claude Code at boot. It is the fastest way to tell whether something comes from the image or from your own setup. Each published version is a git tag in that repo, `b2b-claude-code/`, matching `agent37-claude-code@`. `main` can be ahead of what you run, so take the tag from the template's `image_ref` on [`GET /v1/templates`](/docs/agents-api/templates) and open that tag. ## Troubleshooting | Symptom | Fix | | - | - | | A turn fails with `auth_error` | No Claude account is connected yet: connect one of the three ways above. | | `GET /v1/health?agent=claude-code` reports `"healthy": false` | Same cause: the harness reports unhealthy until an account is connected. `ok: true` alone only means the gateway is up. | # Self-host Claude Managed Agents Source: https://www.agent37.com/docs/agents-api/claude-managed-agents Run the tools of Claude Managed Agents on an always-on Agent37 instance: Anthropic runs the agent loop, your instance runs every command and keeps every file. [Claude Managed Agents](https://platform.claude.com/docs/en/managed-agents/overview) runs Claude's agent loop on Anthropic's platform. With a [self-hosted sandbox](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes), the agent's tools run on infrastructure you choose instead of Anthropic's cloud sandbox. An Agent37 instance makes a good one: an always-on Linux computer that runs Anthropic's environment worker as its main process, keeps the agent's files on its own disk, and bills by the minute, from \$4.76 a month. This is not the same as [Host Claude Code](/docs/agents-api/claude-code). There, the whole agent runs on the instance and you talk to it through the Agent API. Here, Anthropic runs the agent, you talk to it through the Claude API, and the instance runs its tool calls. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt and https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes. I want my Claude Managed Agents sessions to run their tools on an Agent37 instance instead of Anthropic's cloud sandbox. Build the worker image from the Dockerfile on the Agent37 "Self-host Claude Managed Agents" page with npx agent37 templates build . --name claude-managed-agents-worker, then create an instance from that template with ANTHROPIC_ENVIRONMENT_KEY and ANTHROPIC_ENVIRONMENT_ID in env. Done when GET https://api.anthropic.com/v1/environments/{id}/work/stats shows workers_polling of 1 and a session on that environment runs a bash command on the instance. My keys are in AGENT37_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_ENVIRONMENT_KEY, and ANTHROPIC_ENVIRONMENT_ID. ``` ## How it works * **Anthropic** runs the agent loop and the model. Each session you create on a self-hosted environment waits in that environment's work queue. * **Your Agent37 instance** runs `ant beta:worker poll`, the environment worker from Anthropic's `ant` CLI. It claims a session from the queue, runs the agent's bash and file tools in `/workspace`, and posts the results back. It works one session at a time. * The worker only makes outbound HTTPS calls to `api.anthropic.com`. The instance needs no public URL, open port, or webhook. ## Before you begin * An Agent37 API key, exported as `AGENT37_API_KEY`. Create one in the [dashboard](https://www.agent37.com/dashboard/cloud). * A Claude API key, exported as `ANTHROPIC_API_KEY`. * Node.js, for the `npx agent37` CLI that builds the image. No local Docker is needed. ## 1. Create a self-hosted environment Create the environment with the Claude API, or in the [Claude Console](https://platform.claude.com/workspaces/default/environments) under **Environments** > **New** > **Self-hosted**: ```bash curl theme={null} curl https://api.anthropic.com/v1/environments \ -H "x-api-key: $ANTHROPIC_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "anthropic-beta: managed-agents-2026-04-01" \ -H "content-type: application/json" \ -d '{ "name": "agent37", "config": { "type": "self_hosted" } }' ``` Then open the environment in the Console and click **Generate environment key**. Key generation is Console-only. Export both values: ```bash theme={null} export ANTHROPIC_ENVIRONMENT_ID="env_..." export ANTHROPIC_ENVIRONMENT_KEY="sk-ant-oat01-..." ``` The environment key is what the worker authenticates with. A Claude API key in its place fails with `401 Invalid bearer token`. ## 2. Build the worker image Put this `Dockerfile` in an empty folder: ```dockerfile Dockerfile theme={null} FROM node:22-bookworm-slim # Tools the agent can reach for from its bash tool; add your own here. RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl git python3 ripgrep tini \ && rm -rf /var/lib/apt/lists/* # The ant CLI ships Anthropic's environment worker. ARG ANT_VERSION=1.35.0 RUN curl -fsSL "https://github.com/anthropics/anthropic-cli/releases/download/v${ANT_VERSION}/ant_${ANT_VERSION}_linux_amd64.tar.gz" \ | tar -xz -C /usr/local/bin ant # The agent's bash tool runs on a terminal: without this, `git log` waits on a pager until the tool times out. ENV PAGER=cat WORKDIR /workspace ENTRYPOINT ["tini", "--", "ant", "beta:worker", "poll", "--workdir", "/workspace"] ``` The worker is the image's main process, and every bash command the agent issues runs inside this image, so install whatever your agent needs: a language runtime, a CLI, your internal packages. `tini` reaps the background processes those commands leave behind. The image runs on `linux/amd64`, which is why it fetches the amd64 build of `ant`; newer versions are on the [ant releases page](https://github.com/anthropics/anthropic-cli/releases). Build it on Agent37 and publish it as a [workspace template](/docs/agents-api/templates): ```bash theme={null} npx agent37 templates build . --name claude-managed-agents-worker ``` The build runs on Agent37's infrastructure and streams its log to your terminal. See [Build a custom image](/docs/agents-api/custom-image) for build secrets, private base images, and updates. ## 3. Start the worker Create an instance from the template, with the environment id and key as [instance env](/docs/agents-api/instance-environment): ```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\": \"claude-managed-agents-worker\", \"env\": { \"ANTHROPIC_ENVIRONMENT_ID\": \"$ANTHROPIC_ENVIRONMENT_ID\", \"ANTHROPIC_ENVIRONMENT_KEY\": \"$ANTHROPIC_ENVIRONMENT_KEY\" } }" ``` The instance is `running` within seconds, and the worker starts polling. Leave [auto-sleep](/docs/agents-api/instance-auto-sleep) off: the worker's outbound polling does not count as activity, so a sleeping instance stops claiming sessions. Confirm the worker is connected. `workers_polling` should read `1`: ```bash curl theme={null} curl https://api.anthropic.com/v1/environments/$ANTHROPIC_ENVIRONMENT_ID/work/stats \ -H "x-api-key: $ANTHROPIC_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "anthropic-beta: managed-agents-2026-04-01" ``` ```json response theme={null} { "type": "work_queue_stats", "depth": 0, "pending": 0, "oldest_queued_at": null, "workers_polling": 1 } ``` The worker's own output is in the instance [logs](/docs/agents-api/logs): `idle; polling for work` while it waits, then `claimed work` and `executing tool` lines once a session runs. ## 4. Run a session Any Managed Agents agent works; nothing in the agent says where its tools run. If you don't have one yet, create one with the built-in toolset: ```bash curl theme={null} curl https://api.anthropic.com/v1/agents \ -H "x-api-key: $ANTHROPIC_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "anthropic-beta: managed-agents-2026-04-01" \ -H "content-type: application/json" \ -d '{ "name": "Agent37 assistant", "model": "claude-sonnet-5", "system": "You are a helpful assistant.", "tools": [{ "type": "agent_toolset_20260401" }] }' ``` Start a session on your self-hosted environment, then send it a message: ```bash curl theme={null} curl https://api.anthropic.com/v1/sessions \ -H "x-api-key: $ANTHROPIC_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "anthropic-beta: managed-agents-2026-04-01" \ -H "content-type: application/json" \ -d "{ \"agent\": \"$AGENT_ID\", \"environment_id\": \"$ANTHROPIC_ENVIRONMENT_ID\" }" curl https://api.anthropic.com/v1/sessions/$SESSION_ID/events \ -H "x-api-key: $ANTHROPIC_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "anthropic-beta: managed-agents-2026-04-01" \ -H "content-type: application/json" \ -d '{ "events": [{ "type": "user.message", "content": [{ "type": "text", "text": "Run hostname and save the output to /workspace/hello.txt." }] }] }' ``` Read the turn back with `GET /v1/sessions/$SESSION_ID/events`, or stream it; see Anthropic's [events and streaming](https://platform.claude.com/docs/en/managed-agents/events-and-streaming). The `bash` tool result shows the command ran on your instance, with the instance id as the hostname: ```json response theme={null} { "type": "user.tool_result", "content": [{ "type": "text", "text": "ab12cd34ef\n" }] } ``` ## Work with the agent's files Everything the agent writes stays on the instance's disk, across sessions and restarts. Read it from your backend with [exec](/docs/agents-api/exec): ```bash curl theme={null} curl -X POST https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec \ -H "X-Agent37-Key: $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "command": "cat /workspace/hello.txt" }' ``` Anthropic doesn't mount files or GitHub repositories into a self-hosted sandbox, so stage inputs yourself: `git clone` or `curl` them into `/workspace` over exec before you start the session. To watch the agent work, [SSH](/docs/agents-api/ssh) into the instance. ## Scale out * **More sessions at once.** A worker holds one session until a minute after its turn ends (the `--max-idle` flag, default `1m`), so the next message in a conversation continues without going back to the queue. Other sessions wait their turn. To run several at once, create more instances from the same template with the same env: every worker on an environment polls one queue, and each session goes to the first free worker. A later message on a session that was let go queues again and runs on whichever worker is free. * **Heavier tools.** [Resize](/docs/agents-api/instances/resize) the instance for more CPU, memory, or disk. * **Separate customers.** Sessions on one instance share its disk. When customers must not see each other's files, give each one its own self-hosted environment and instance. * **Memory stores.** The `ant` worker doesn't mount [memory stores](https://platform.claude.com/docs/en/managed-agents/memory). If your sessions attach them, replace the `ENTRYPOINT` with the Python, TypeScript, or Go SDK's `EnvironmentWorker`; see [Use memory stores](https://platform.claude.com/docs/en/managed-agents/self-hosted-sandboxes#use-memory-stores). ## One instance per session To give every session its own computer, claim the work yourself instead of running `ant beta:worker poll`. A small orchestrator on your server long-polls the environment's queue with the Anthropic SDK's `WorkPoller`, creates an instance with `auto_sleep: true` for each new session, and starts `ant beta:worker run` on it over [exec](/docs/agents-api/exec), which wakes the instance if it is asleep. The session's files stay on its instance between turns, the instance sleeps and bills disk alone while nobody is talking to it, and sessions run in parallel, one per instance. The [claude-managed-agents example](https://github.com/agent37-platform/examples/tree/main/claude-managed-agents) is a complete orchestrator in about 100 lines of Node.js, with a chat script and a parallel-sessions demo. ## Cost The default 2 vCPU / 4 GB instance costs \$4.76 a month running around the clock, metered per minute from your Agent37 wallet; larger shapes are on [Instances](/docs/agents-api/instance-sizing). Model usage bills to your Claude account, not to Agent37. ## Troubleshooting | Symptom | Fix | | - | - | | `workers_polling` stays `0` | Read the instance [logs](/docs/agents-api/logs). `401 Unauthorized` with `Invalid bearer token` means `ANTHROPIC_ENVIRONMENT_KEY` is wrong or is a Claude API key. Env is set at create, so delete the instance and create it again with the right key. | | `restart_count` climbs in the logs health readout | The worker exits on a permanent error and the platform starts it again. The error is at the top of the logs. | | A session stays queued | Every worker is busy, or none is polling. Check that `workers_polling` is at least `1`, and add instances if sessions pile up in `depth`. | | A turn stalls after the instance restarts mid-session | The stopped worker still held the session. Anthropic releases it within a few minutes, and the session then runs on the next free worker. Restart, resize, or update between sessions to avoid the wait. | # Host Codex Source: https://www.agent37.com/docs/agents-api/codex Run OpenAI's Codex on its own always-on instance, signed in to your own OpenAI account. The `agent37-codex` [template](/docs/agents-api/templates) runs Codex, OpenAI's coding agent, on an Agent37 instance: an always-on computer with the same chat API, sessions, files, and URLs as every other template. The image is lean, with no browser and no desktop. Codex runs on **your own OpenAI account**: Agent37 never supplies or bills OpenAI model usage, and there is no managed-model option on this template, so connect your account before the first chat turn. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt. I want Codex on its own always-on instance, signed in to my own OpenAI account. Create it with POST /v1/instances using the agent37-codex template. If OPENAI_API_KEY is set in my environment, pass it in env on that call; otherwise mint a signed URL for port 7681 and give it to me so I can run codex login --device-auth in the terminal. Done when a message to https://{instanceId}.agent37.app/v1/responses returns a reply from Codex. My key is in AGENT37_API_KEY. ``` ## Create the instance One call to the Sandbox API at `https://api.agent37.com`: ```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-codex" }' ``` The `201` response carries the instance id and its URL, `https://{instanceId}.agent37.app`, which routes to the gateway on port `3737`. The terminal is on port `7681` and the file browser on `8080`, each at `https://{instanceId}-{port}.agent37.app`; see [Instance and preview URLs](/docs/agents-api/urls). ## Connect your OpenAI account Two ways to connect. Until one of them is done, a chat turn fails with `auth_error`, and `GET /v1/health?agent=codex` on the instance URL reports `"healthy": false`. **Log in from the instance terminal.** Mint a [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls) for the terminal port: ```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": 7681 }' ``` Open the returned `url` in a browser and run `codex login --device-auth` in the shell. It prints a URL and a one-time code: open the URL, enter the code, and approve with your ChatGPT account. The login persists on the instance volume, so it survives restarts and updates. Starting a device login logs out any API-key login on the instance, so use one method per instance. **Set `OPENAI_API_KEY`.** The way to go when your app provisions instances headlessly. Pass your OpenAI API key (as `OPENAI_API_KEY`, or `CODEX_API_KEY`) as [instance env](/docs/agents-api/instance-environment) on the create call: ```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-codex", "env": { "OPENAI_API_KEY": "..." } }' ``` The image runs `codex login --with-api-key` at boot to activate the key; a bare environment variable on its own does not authenticate. ## Send the first message Chat is the instance URL plus a path, with the same `sk_live_` key sent as the `X-Agent37-Key` header. `codex` is the template's default agent, so there is no `agent` field to pass: ```bash curl theme={null} curl https://ab12cd34ef.agent37.app/v1/responses \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "input": "Clone github.com/expressjs/express and summarize how routing works." }' ``` Set `"stream": true` to receive the turn as Server-Sent Events instead of one JSON body, and reuse the returned `session_id` to continue the thread. Codex mints its own session ids, so start a thread by omitting `session_id`; you cannot bring an unknown id on the first turn. See [Send a message](/docs/agents-api/chat) and [Streaming](/docs/agents-api/streaming). ## Models `GET /v1/models?agent=codex` on the instance lists Codex's own live model catalog, the `gpt-5.x` family. Pass an id as `model` on a turn, and set `reasoning_effort` per turn; `ultra` maps to Codex's multi-agent mode, and a level the target model does not advertise runs at the nearest level below it. The response's `reasoning_effort` reports the level applied. Usage bills to your OpenAI account, not your Agent37 wallet. See [Models](/docs/agents-api/models). ## Bring your own model Codex can run against a different provider entirely: point it at any [Responses](https://platform.openai.com/docs/api-reference/responses)-compatible endpoint with `OPENAI_BASE_URL` in the instance `env` on the create call: ```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-codex", "env": { "OPENAI_BASE_URL": "https://your-endpoint.example.com/v1", "OPENAI_API_KEY": "" } }' ``` No OpenAI account is needed: model usage bills the key behind the endpoint you point `OPENAI_BASE_URL` at, and health reports healthy once the instance is up. Codex is built for OpenAI models; pointing it at another family can mishandle the tool calls it edits files with. ## Connect apps [App integrations](/docs/agents-api/integrations) work here like on every system template: drive the `/v1/integrations` endpoints to connect your users' Gmail, Slack, Notion, and hundreds of other apps, and the platform registers its Composio MCP server with Codex automatically (in `~/.codex/config.toml`). ## Custom image To add your own tools, build on the clean base image, which ships Codex and the gateway with no managed integrations, the same pattern as `hermes-base`: ```dockerfile theme={null} FROM ghcr.io/agent37-platform/codex-base:latest ``` See [Build a custom image](/docs/agents-api/custom-image). ## Image source The image behind `agent37-codex` is built from a public Dockerfile, [`images/b2b/codex`](https://github.com/agent37-platform/templates/tree/main/images/b2b/codex) in [agent37-platform/templates](https://github.com/agent37-platform/templates). Read it, or point your coding agent at it, to see exactly what the instance has installed and how the entrypoint configures Codex at boot. It is the fastest way to tell whether something comes from the image or from your own setup. Each published version is a git tag in that repo, `b2b-codex/`, matching `agent37-codex@`. `main` can be ahead of what you run, so take the tag from the template's `image_ref` on [`GET /v1/templates`](/docs/agents-api/templates) and open that tag. ## Troubleshooting | Symptom | Fix | | - | - | | A turn fails with `auth_error` | No OpenAI account is connected yet: connect one of the two ways above. The `hint` tells the user to run `codex login --device-auth` or set `OPENAI_API_KEY`. | | `GET /v1/health?agent=codex` reports `"healthy": false` | Same cause: the harness reports unhealthy until an account is connected. `ok: true` alone only means the gateway is up. | | A first turn on a `session_id` you chose is rejected with `400 validation_error` (`param: session_id`) | Codex mints its own session ids: a client cannot bring an unknown `session_id` on the first turn. Omit it to start a thread, then reuse the id the response returns. Sessions you start in the terminal appear in `GET /v1/sessions?agent=codex` on their own. | | A turn returns `503 agent_unavailable` | The Codex binary is not present on this instance, so a turn that has to create or resolve a session cannot run. Unlike Hermes (which returns `200` with `status: "failed"`), Codex answers `503`. Recreate or [update](/docs/agents-api/instances/update) the instance to a template revision that ships Codex. | # 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. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt. I want integrations on my own Composio: my API key, my users' connected accounts, my own metering. Clone https://github.com/agent37-platform/hermes-openclaw-composio and follow the guide's Get started steps; setup needs my COMPOSIO_API_KEY and a Supabase access token (ask me for both). Done when an agent I create in the kit calls a connected service and the call shows in its Integrations tab. My key is in AGENT37_API_KEY. ``` > [card] [agent37-platform/hermes-openclaw-composio](https://github.com/agent37-platform/hermes-openclaw-composio) > > 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 Sandbox 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 **1. Get your keys** 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. **2. Clone it** ```bash theme={null} git clone https://github.com/agent37-platform/hermes-openclaw-composio cd hermes-openclaw-composio ``` **3. Run setup** 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`. **4. Run it** ```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. > [!NOTE] > > 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 Persistent sandboxes for code, services, and agents: instances, templates, networking, and APIs. Agent37 Cloud provides persistent sandboxes for code, services, and agents. Each sandbox is an **instance**. A **template** chooses the software installed inside it. The **Sandbox API** manages your instances, and authenticated HTTPS URLs let you reach the services they run. Choose an agent template when you also need the **Agent API** for conversations and tools. An instance can also be a [full Linux VM](/docs/agents-api/vm-instances): set `"type": "vm"` for its own kernel, Docker-in-Docker, Compose, or nested KVM. Use `agent37-ubuntu` for Docker and a Linux desktop, or [`agent37-android`](/docs/agents-api/android) for Android 14 with ADB and a browser screen. Both use the same Sandbox API. ## Two planes, one key | Plane | Base URL | What it serves | | - | - | - | | **Sandbox API** | `https://api.agent37.com/v1` | Creates and manages sandboxes: [instances](/docs/agents-api/instances), [templates](/docs/agents-api/templates), [budgets](/docs/agents-api/budgets). | | **Sandbox API, on the instance's own machine** | `https://sandbox.agent37.app` | [exec](/docs/agents-api/exec) and [logs](/docs/agents-api/logs), answered by the machine the instance runs on. The `api.agent37.com` form of both still works and returns the same body. | | **Agent API** | `https://{instanceId}.agent37.app` | An API included in agent templates: `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. `api.agent37.com` takes `Authorization: Bearer sk_live_...`. Everything on `agent37.app` (instance URLs and `sandbox.agent37.app`) takes 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), or get your first one with [`POST /v1/signup`](/docs/agents-api/signup) if the caller is a program; each key is scoped to one workspace. On the Sandbox 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 service listening on the requested port. On an agent template's default port, that service is the agent gateway. 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. Create and manage a sandbox through the Sandbox API, regardless of which template it runs: ```bash theme={null} curl https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{"command": "uname -s"}' ``` > [!NOTE] > > 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 for code, services, or an agent harness. 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. A default instance runs one container; a VM can run Docker and multiple containers inside it. Create one per user or workload. Runs on dedicated servers in the United States. | | **Template** | The image an instance runs, with an optional default port. Choose a system template or register your own Docker image. | ## 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 an agent template such as `agent37-hermes`, that makes it the chat URL. The default template, `agent37-base`, has no default port, so its instance URL has nothing behind it. * 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. ## Agent template resources These resources belong to the Agent API supplied by agent templates. A custom image or a web app such as n8n does not acquire a chat API just by running in a sandbox. | Concept | What it is | | - | - | | **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. | > [!NOTE] > > **The agent is not the model.** The *template* installs the agent software, the *gateway* inside the instance runs your sessions on it (Hermes, OpenClaw, Claude Code, Codex, Grok, OpenCode, and Pi are the live agents today), and the *model* is the LLM the agent thinks with, set as `model` on a turn (plus `provider` on Hermes; see [Models](/docs/agents-api/models) for how each harness applies it). ### How they fit together * Create an **instance** for a user or workload with `POST /v1/instances` on the Sandbox 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; any request to its URL wakes it transparently. See [Auto-sleep](/docs/agents-api/instance-auto-sleep). * On an agent template, 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`. ## 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). Model usage on `agent37-claude-code` and `agent37-codex` is the exception: [Claude Code](/docs/agents-api/claude-code) runs on your own Anthropic account and [Codex](/docs/agents-api/codex) on your own OpenAI account, so Agent37 never supplies or bills their model spend. 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 Sandbox 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 > [card] [Create an instance](/docs/agents-api/instances/create) > > Start a persistent sandbox with one request. > [card] [Bring your own image](/docs/agents-api/custom-image) > > Choose the tools and services your sandbox runs. > [card] [Instance and preview URLs](/docs/agents-api/urls) > > Reach any service over authenticated HTTPS. > [card] [Use an agent template](/docs/agents-api/agent-quickstart) > > Add conversations with a hosted harness. # Crons Source: https://www.agent37.com/docs/agents-api/crons Schedule a message to your agent. Each firing wakes the instance, runs the turn on its own session, and lets the instance go back to sleep. A **cron** is one sentence and a schedule: say this to my agent, every weekday at 9. Each firing sends the prompt to [`POST /v1/responses`](/docs/agents-api/chat) on that instance's own gateway, on a fresh [session](/docs/agents-api/sessions), and the request wakes the instance on its way in. That last part is the reason to use this instead of the scheduler inside the agent. A crontab inside the container only runs while the container is running, so on an instance with [auto-sleep](/docs/agents-api/instance-auto-sleep) it stops firing the moment the instance sleeps, and it keeps the instance awake for nothing when it doesn't. A platform cron is outside the instance: it fires whether the instance is awake or asleep, and between firings the instance is free to sleep and cost you nothing but disk. Crons belong to an instance. Deleting the instance deletes its crons. `agent37-base` and `agent37-n8n` take none: they run no agent, so there is nothing to send a message to. ## Create a cron `prompt` and `schedule` are required. ```bash curl wrap theme={null} curl -X POST \ https://api.agent37.com/v1/instances/ab12cd34ef/crons \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "prompt": "Summarize the files in ~/reports.", "schedule": "0 9 * * 1-5", "agent": "hermes", "profile": "default", "timezone": "America/New_York", "name": "Weekday briefing", "enabled": true }' ``` ```python Python wrap theme={null} import requests response = requests.post( "https://api.agent37.com/v1/instances/ab12cd34ef/crons", headers={ "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }, json={ "prompt": "Summarize the files in ~/reports.", "schedule": "0 9 * * 1-5", "agent": "hermes", "profile": "default", "timezone": "America/New_York", "name": "Weekday briefing", "enabled": True }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/crons", { method: "POST", headers: { "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }, body: JSON.stringify({ "prompt": "Summarize the files in ~/reports.", "schedule": "0 9 * * 1-5", "agent": "hermes", "profile": "default", "timezone": "America/New_York", "name": "Weekday briefing", "enabled": true }), }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json response wrap theme={null} { "id": "9903c325ee3c", "name": "Weekday briefing", "agent": "hermes", "profile": null, "prompt": "Summarize the files in ~/reports.", "schedule": "0 9 * * 1-5", "timezone": "America/New_York", "enabled": true, "last_run": null, "next_run": 1790154000, "created": 1790067720 } ``` **prompt** `string` *required* What to say to the agent. The same thing you would send as `input` on `POST /v1/responses`, so it can be a whole standing instruction, not just a line. Up to 8,000 characters. **schedule** `string` *required* A five-field cron expression: minute, hour, day of month, month, day of week. `0 9 * * 1-5` is 9am on weekdays; `*/15 * * * *` is every fifteen minutes; `* * * * *` is every minute. Ranges, lists, steps, and three-letter day and month names all work. There is no seconds field: a six-field expression is rejected. **agent** `string` Which harness runs the firing, for an image that ships more than one: `hermes`, `openclaw`, `claude-code`, `codex`, `opencode`, `grok` or `pi`. Omit it and the firing goes to the template's own agent, which is what a single-harness image wants. Naming a harness the image does not ship means the turn never starts: the run is recorded as `skipped` with `wake_failed`. **profile** `string` Which [profile](/docs/agents-api/sessions#profiles) the firing runs in, for an instance that hosts several distinct agents: the same `profile` as on [`POST /v1/responses`](/docs/agents-api/chat). Omit it, or send `default`, for the agent's own setup. Supported on `hermes` today: a `profile` with any other harness is `400 invalid_request`, and so is a name that is not lowercase letters, digits, `-` or `_`, or an instance whose image is older than profiles ([update it](/docs/agents-api/instances/update) first). Whether the instance has the profile is checked when the cron fires: one it does not have means the turn never starts, and the run is recorded as `skipped` with `wake_failed`. **timezone** `string` (default: UTC) An IANA timezone name, for example `America/New_York`. The schedule is read in this zone, so "9am" stays 9am across daylight saving. **name** `string` A label for your own UI. Up to 80 characters. **enabled** `boolean` (default: true) `false` creates it paused. A paused cron has `next_run: null` and never fires. ## The cron object **id** `string` 12-character hex id. **last\_run** `number | null` When it last fired on its schedule, epoch seconds. `null` until it has. [Running it now](#run-one-now) does not change it. **next\_run** `number | null` When it fires next, epoch seconds. `null` while `enabled` is `false`. **created** `number` Epoch seconds. ## List, read, edit, delete ```bash wrap theme={null} GET /v1/instances/{id}/crons # { "data": [ ... ] }, oldest first GET /v1/instances/{id}/crons/{cronId} PATCH /v1/instances/{id}/crons/{cronId} DELETE /v1/instances/{id}/crons/{cronId} # { "id": "...", "deleted": true } ``` `PATCH` takes any of the create fields and changes only the keys you send: `"agent": null` hands the firing back to the template's own agent, and `"profile": null` hands it back to the agent's own setup. Changing `schedule`, `timezone` or `enabled` recomputes `next_run` from now, so a new schedule never replays a window that has already passed; editing only the `prompt` or the `name` leaves the next firing exactly where it was. Send only the fields you want to change. This example sets every field and pauses the cron: ```bash curl wrap theme={null} curl -X PATCH \ https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "prompt": "Summarize the files in ~/reports.", "schedule": "0 9 * * 1-5", "agent": "hermes", "profile": "default", "timezone": "America/New_York", "name": "Weekday briefing", "enabled": false }' ``` ```python Python wrap theme={null} import requests response = requests.patch( "https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c", headers={ "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }, json={ "prompt": "Summarize the files in ~/reports.", "schedule": "0 9 * * 1-5", "agent": "hermes", "profile": "default", "timezone": "America/New_York", "name": "Weekday briefing", "enabled": False }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c", { method: "PATCH", headers: { "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }, body: JSON.stringify({ "prompt": "Summarize the files in ~/reports.", "schedule": "0 9 * * 1-5", "agent": "hermes", "profile": "default", "timezone": "America/New_York", "name": "Weekday briefing", "enabled": false }), }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` Delete acts once: a repeat is `404`. An instance holds at most 50 crons. ### List crons ```bash curl wrap theme={null} curl -X GET \ https://api.agent37.com/v1/instances/ab12cd34ef/crons \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.get( "https://api.agent37.com/v1/instances/ab12cd34ef/crons", headers={ "Authorization": "Bearer sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/crons", { method: "GET", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ### Read a cron ```bash curl wrap theme={null} curl -X GET \ https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.get( "https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c", headers={ "Authorization": "Bearer sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c", { method: "GET", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ### Delete a cron ```bash curl wrap theme={null} curl -X DELETE \ https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.delete( "https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c", headers={ "Authorization": "Bearer sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c", { method: "DELETE", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ## Run one now `POST /v1/instances/{id}/crons/{cronId}/run` fires the cron immediately, whether or not it is enabled, without touching its schedule. It answers `202` with the run record as soon as the run is recorded, before the turn is sent, so a run that reads `triggered` here can still end up `skipped` with `wake_failed`. ```bash curl wrap theme={null} curl -X POST \ https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c/run \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.post( "https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c/run", headers={ "Authorization": "Bearer sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c/run", { method: "POST", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ## Run history `GET /v1/instances/{id}/crons/{cronId}/runs` returns the latest 50 firings, newest first. ```bash curl wrap theme={null} curl -X GET \ https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c/runs \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.get( "https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c/runs", headers={ "Authorization": "Bearer sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/instances/ab12cd34ef/crons/9903c325ee3c/runs", { method: "GET", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json wrap theme={null} { "data": [ { "id": "0f3a9c21", "ran_at": 1790067720, "session_id": "7f3e0b6c52a949d2b1c4a8e9d0f31726", "status": "triggered", "reason": null }, { "id": "b71e40aa", "ran_at": 1789981320, "session_id": null, "status": "skipped", "reason": "instance_stopped" } ] } ``` **status** `string` `triggered` (the turn was started) or `skipped` (nothing was sent). **session\_id** `string | null` The session the firing ran on. Read what the agent actually did with [`GET /v1/sessions/{session_id}`](/docs/agents-api/sessions#retrieve-a-session-with-history) on the instance URL, adding `?profile=` when the cron names one. When the firing goes to `hermes`, `openclaw` or `claude-code` (the cron's `agent`, or else the template's own), it is set when the run is recorded, so the `202` from [running it now](#run-one-now) already carries it. For `codex`, `grok`, `opencode` and `pi`, whose harnesses issue their own session ids, and on a workspace template when the cron sets no `agent`, it is `null` at first and filled in once the turn finishes. It stays `null` on a turn still running about 13 minutes after the firing (the turn itself carries on). On a skip it is `null`, except a `wake_failed` run that was already given an id: that session never started. **reason** `string | null` Why it was skipped: `instance_stopped`, `past_due`, `free_hours_exhausted`, or `wake_failed`. > [!NOTE] > > A cron is fire and forget. `triggered` means the turn was started, not that it succeeded. The agent's answer, its tool calls, and any error live in the session, which is where you read them. ## What fires, and what doesn't A sleeping instance is woken and the turn runs. An instance you [stopped](/docs/agents-api/instance-lifecycle) stays stopped: a schedule never undoes an explicit stop, and the firing is recorded as `skipped` with `instance_stopped`. The same goes for an instance suspended for [non-payment](/docs/agents-api/billing) (`past_due`). Each firing opens its own session, so a cron never interleaves with your chat thread and 15 crons never turn into 15 conversations in the same transcript. If a firing is still running when the next one is due, the next one starts its own session as usual. A window the platform misses (a schedule that came due while the instance was stopped, or a cron paused for a week and then re-enabled) is skipped, never backfilled. ## Your agent can schedule itself The agent running on the instance can create its own crons, with the `agent37 cron` CLI baked into every Agent37 agent image: ```bash wrap theme={null} agent37 cron add --schedule "0 9 * * 1-5" --prompt "Check my inbox and summarise anything urgent." --timezone America/New_York ``` Also `agent37 cron list`, `agent37 cron update [--pause|--resume]`, `agent37 cron remove ` and `agent37 cron runs `. The images tell the agent this exists, so "check my email every weekday at 9" is usually enough; no key is involved, since the CLI uses the credential the instance already holds. Anything it creates is an ordinary cron: it appears in `GET /v1/instances/{id}/crons`, and you can edit or delete it there like any other. ## Cost A cron costs nothing to hold. What it costs is what the turn costs: the compute minutes the instance is awake for, and the [managed services](/docs/agents-api/managed-services) the turn uses, exactly as if you had sent the message yourself. An instance with auto-sleep on, fired once an hour, is billed for the minutes it is actually working plus disk for the rest of the day. # Cursor Self-Hosted Machines on Agent37 Source: https://www.agent37.com/docs/agents-api/cursor Run the tool calls of Cursor Cloud Agents on an always-on Agent37 instance: Cursor runs the agent, your instance runs every command and keeps the checkout. [Cursor Cloud Agents](https://cursor.com/docs/cloud-agent) can run their tool calls on [Self-Hosted Machines](https://cursor.com/docs/cloud-agent/self-hosted). Cursor keeps the agent loop, the model, and the Cloud Agent experience; a worker you run executes the file edits and terminal commands. An Agent37 instance makes a good one: an always-on Linux computer that keeps your checkout, dependencies, and build cache on its own disk between sessions, and bills by the minute, from \$4.76 a month. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/agents-api/cursor and follow the setup. Build the worker image, create an Agent37 instance with my Cursor API key, clone my repository into /home/node/repo, and start the Cursor worker on it. My keys are in AGENT37_API_KEY and CURSOR_API_KEY, and the repository is in REPO_URL. Done when a Cursor agent started on the machine named agent37 writes hello.txt in the repository and Agent37 exec reads it back. ``` ## How it works * **Cursor** runs the agent loop and the model. You start agents from [cursor.com/agents](https://cursor.com/agents), the desktop app, Slack, GitHub, Linear, or the [Cloud Agents API](https://cursor.com/docs/cloud-agent/api/endpoints). * **Your Agent37 instance** runs `agent worker`, the worker from Cursor's CLI. It executes each tool call in your checkout and sends the results back. * The worker only makes outbound HTTPS connections to Cursor (`api2.cursor.sh`, `api2direct.cursor.sh`, and an artifact bucket). The instance needs no public URL or open port. ## Before you begin * An Agent37 API key in `AGENT37_API_KEY` and a funded [workspace wallet](/docs/agents-api/billing). Create a key in the [dashboard](https://www.agent37.com/dashboard/cloud). * A Cursor user API key from [Cursor Dashboard > API Keys](https://cursor.com/dashboard/api), exported as `CURSOR_API_KEY`. It connects the instance as one of your [My Machines](https://cursor.com/docs/cloud-agent/self-hosted/my-machines), which needs no Enterprise plan. For a team fleet, see [Team Pools](#team-pools). * The repository the agent works on, exported as `REPO_URL` (for example `https://github.com/your-org/your-repo`). * Node.js, `curl`, and `jq` on your computer. The cloud build needs no local Docker. ## 1. Build the worker image Put this `Dockerfile` in an empty folder: ```dockerfile Dockerfile theme={null} FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl git python3 ripgrep tini \ && rm -rf /var/lib/apt/lists/* RUN curl https://cursor.com/install -fsS | HOME=/opt/cursor-cli bash \ && ln -s /opt/cursor-cli/.local/bin/agent /usr/local/bin/agent USER node WORKDIR /home/node ENTRYPOINT ["tini", "--"] CMD ["sleep", "infinity"] ``` The image installs Cursor's CLI under `/opt` and puts `agent` on the `PATH`, since `/home/node` is the instance's persistent home and is mounted over the image at runtime. Add the toolchain your repository needs in the same way, outside `/home/node`. The main process keeps the instance available; you start the worker after cloning. Build it as a [workspace template](/docs/agents-api/templates): ```bash theme={null} npx agent37 templates build . --name cursor-worker ``` Omit `--default-port`: the worker needs no listening port. ## 2. Create the instance ```bash theme={null} jq -n '{ template: "cursor-worker", auto_sleep: false, env: { CURSOR_API_KEY: env.CURSOR_API_KEY } }' | curl --fail-with-body -sS https://api.agent37.com/v1/instances \ -H "Authorization: Bearer $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ --data-binary @- > instance.json export INSTANCE_ID=$(jq -er '.id' instance.json) ``` Leave [auto-sleep](/docs/agents-api/instance-auto-sleep) off: the worker's outbound connection does not keep an instance awake. The worker reads `CURSOR_API_KEY` from its environment, where commands the agent runs can read it too, so use a key dedicated to this instance. ## 3. Clone the repository ```bash theme={null} jq -n --arg url "$REPO_URL" '{ command: "git clone \($url) /home/node/repo" }' \ | curl --fail-with-body -sS "https://sandbox.agent37.app/v1/instances/$INSTANCE_ID/exec" \ -H "X-Agent37-Key: $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ --data-binary @- ``` Check `exit_code` is `0`. For a private repository, use a URL that carries a token with read access, or [SSH](/docs/agents-api/ssh) into the instance and clone it with your own credentials. The checkout's `origin` remote tells Cursor which repository this machine serves. ## 4. Start the worker ```bash theme={null} curl --fail-with-body -sS \ "https://sandbox.agent37.app/v1/instances/$INSTANCE_ID/exec" \ -H "X-Agent37-Key: $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "command": "nohup agent worker --worker-dir /home/node/repo --name agent37 start > /tmp/cursor-worker.log 2>&1 < /dev/null &" }' ``` A successful exec response only means the launch command ran. Read the worker's log after a few seconds: ```bash theme={null} curl --fail-with-body -sS \ "https://sandbox.agent37.app/v1/instances/$INSTANCE_ID/exec" \ -H "X-Agent37-Key: $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "command": "cat /tmp/cursor-worker.log" }' ``` `stdout` contains `Worker is now running` with the name `agent37`. If it does not, run `agent worker --worker-dir /home/node/repo debug` through exec: it checks the key, the repository, and whether Cursor can see the worker. ## 5. Run an agent and read its file Open [cursor.com/agents](https://cursor.com/agents), pick **agent37** in the environment menu, and send a task. Or start one through the [Cloud Agents API](https://cursor.com/docs/cloud-agent/api/endpoints#create-an-agent): ```bash theme={null} jq -n --arg url "$REPO_URL" '{ prompt: { text: "Run hostname and save its output to hello.txt in the repository root. Do not commit or push." }, env: { type: "machine", name: "agent37" }, repos: [{ url: $url }] }' | curl --fail-with-body -sS https://api.cursor.com/v1/agents \ -u "$CURSOR_API_KEY:" \ -H "Content-Type: application/json" \ --data-binary @- > agent.json export CURSOR_AGENT_ID=$(jq -er '.agent.id' agent.json) export CURSOR_RUN_ID=$(jq -er '.run.id' agent.json) ``` Check the run until its `status` is `FINISHED`: ```bash theme={null} curl --fail-with-body -sS \ "https://api.cursor.com/v1/agents/$CURSOR_AGENT_ID/runs/$CURSOR_RUN_ID" \ -u "$CURSOR_API_KEY:" ``` Then read the file directly from your instance: ```bash theme={null} curl --fail-with-body -sS \ "https://sandbox.agent37.app/v1/instances/$INSTANCE_ID/exec" \ -H "X-Agent37-Key: $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "command": "cat /home/node/repo/hello.txt" }' ``` `stdout` is the instance id, which is its hostname. That verifies the Cursor agent ran its command on your Agent37 instance. ## Team Pools On a Cursor Enterprise plan, the same image serves a [Team Pool](https://cursor.com/docs/cloud-agent/self-hosted/pool). Create the instance with a [service account API key](https://cursor.com/docs/account/enterprise/service-accounts) as `CURSOR_API_KEY`, then start the worker with a pool name: ```bash theme={null} curl --fail-with-body -sS \ "https://sandbox.agent37.app/v1/instances/$INSTANCE_ID/exec" \ -H "X-Agent37-Key: $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "command": "nohup agent worker --pool agent37 --idle-release-timeout 0 --worker-dir /home/node/repo start > /tmp/cursor-worker.log 2>&1 < /dev/null &" }' ``` Each Cloud Agent session claims one pool worker at a time, so create one instance per worker you want in the pool. `--idle-release-timeout 0` keeps an always-on worker connected; by default a pool worker exits after an hour idle. Start pool agents with `env: { type: "pool", name: "agent37" }`, or pick the pool on [cursor.com/agents](https://cursor.com/agents). ## Sleep between sessions On a Team Pool, each agent can get its own instance that stops when the agent goes idle and starts again when a follow-up arrives, so you pay the disk rate alone between sessions (see [Billing](/docs/agents-api/billing)). The checkout, dependencies, and build cache stay on the instance's disk. This uses Cursor's [hibernation](https://cursor.com/docs/cloud-agent/self-hosted/pool#hibernation): the [worker controller](https://cursor.com/docs/cloud-agent/self-hosted/pool#worker-controller) runs a spawn hook for every new session and every wake. A My Machines worker cannot sleep, because Cursor has no way to wake it. Give the pool a reconnect window, so Cursor holds a follow-up while its instance starts: ```bash theme={null} curl --fail-with-body -sS https://api.cursor.com/v0/private-workers/pools \ -u "$CURSOR_API_KEY:" \ -H "Content-Type: application/json" \ -d '{ "scope": "team", "poolName": "agent37", "workerReadyTimeoutSeconds": 900 }' ``` Save the spawn hook as `spawn.sh`. It names each instance after its Cursor worker id, so a wake starts the same instance again: ```bash spawn.sh theme={null} #!/usr/bin/env bash set -euo pipefail a37() { curl --fail-with-body -sS "https://api.agent37.com/v1$1" \ -H "Authorization: Bearer $AGENT37_API_KEY" \ -H "Content-Type: application/json" "${@:2}" } a37exec() { curl --fail-with-body -sS "https://sandbox.agent37.app/v1/instances/$1/exec" \ -H "X-Agent37-Key: $AGENT37_API_KEY" \ -H "Content-Type: application/json" "${@:2}" } ID=$(a37 /instances | jq -r --arg w "$CURSOR_AGENT_WORKER_ID" \ 'first(.data[] | select(.name == $w) | .id) // empty') if [ -n "$ID" ]; then a37 "/instances/$ID/start" -X POST > /dev/null else ID=$(jq -n --arg w "$CURSOR_AGENT_WORKER_ID" '{ template: "cursor-worker", name: $w, metadata: { cursor_pool: env.CURSOR_POOL }, env: { CURSOR_API_KEY: env.CURSOR_API_KEY } }' | a37 /instances --data-binary @- | jq -er '.id') fi jq -n --arg w "$CURSOR_AGENT_WORKER_ID" --arg p "$CURSOR_POOL" '{ command: ( "mkdir -p /home/node/work && rm -f /tmp/worker-exited && " + "CURSOR_AGENT_WORKER_ID=\($w) nohup sh -c \"agent worker --pool \($p) " + "--clone-git-repos --idle-release-timeout 600 --worker-dir /home/node/work start; " + "touch /tmp/worker-exited\" > /tmp/cursor-worker.log 2>&1 < /dev/null &" ) }' | a37exec "$ID" --data-binary @- > /dev/null ``` The worker checks out the agent's repositories in `/home/node/work` (`--clone-git-repos` needs a team admin to turn on GitHub token minting for pool workers) and exits after 10 idle minutes. Save this loop as `idle.sh`. Once a minute, it stops every pool instance whose worker has exited: ```bash idle.sh theme={null} #!/usr/bin/env bash a37() { curl --fail-with-body -sS "https://api.agent37.com/v1$1" \ -H "Authorization: Bearer $AGENT37_API_KEY" \ -H "Content-Type: application/json" "${@:2}" } a37exec() { curl --fail-with-body -sS "https://sandbox.agent37.app/v1/instances/$1/exec" \ -H "X-Agent37-Key: $AGENT37_API_KEY" \ -H "Content-Type: application/json" "${@:2}" } while true; do for ID in $(a37 /instances | jq -r '.data[] | select(.status == "running" and .metadata.cursor_pool != null) | .id'); do a37exec "$ID" -d '{"command": "rm /tmp/worker-exited"}' \ | jq -e '.exit_code == 0' > /dev/null \ && a37 "/instances/$ID/stop" -X POST > /dev/null done sleep 60 done ``` Run both on any always-on machine with the Cursor CLI, `curl`, and `jq`, with `AGENT37_API_KEY` set and `CURSOR_API_KEY` set to the service account key: ```bash theme={null} chmod +x spawn.sh idle.sh ./idle.sh & agent worker controller --spawn ./spawn.sh --pool agent37 ``` A wake starts the instance and its worker in a few seconds. A stopped instance keeps billing disk until you delete it. ## Lifecycle and cleanup The worker stops with its instance. If you stop, restart, or update the instance, start the worker again with step 4. The checkout in `/home/node/repo`, with its dependencies and build output, survives all of those. When you no longer need the machine, delete the instance and revoke its Cursor key in the [Cursor dashboard](https://cursor.com/dashboard/api): ```bash theme={null} curl --fail-with-body -sS -X DELETE \ "https://api.agent37.com/v1/instances/$INSTANCE_ID" \ -H "Authorization: Bearer $AGENT37_API_KEY" ``` Agent37 bills compute and storage through your [wallet](/docs/agents-api/billing); Cursor bills model usage through your Cursor plan. ## Provider listing Cursor lists infrastructure partners on its [Self-Hosted Machines integrations](https://cursor.com/docs/cloud-agent/self-hosted/integrations) page, each linking to the partner's own guide. Everything needed to add Agent37 is below. ```text title="Provider description" wrap theme={null} Agent37 Cloud runs Cursor Self-Hosted Machines workers on Linux instances. Build the worker image once, keep the checkout, dependencies, and build cache on the instance's disk between sessions, and create and manage workers through the Agent37 Sandbox API. On Team Pools, each agent's instance stops when it goes idle and starts again in seconds on a follow-up, billing disk alone in between. ``` ```markdown title="Partner guide entry" theme={null} - **Agent37.** [Cursor Self-Hosted Machines on Agent37](https://www.agent37.com/docs/agents-api/cursor) ``` # 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 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 registry, public or private | `image_ref`, plus `registry_auth` for a private one | A fully qualified reference; for a private registry, a username and password Agent37 uses once to copy the image. | Your Dockerfile and files upload straight to the build, so a private codebase never needs a registry. A `FROM` on a private registry and a `RUN` step that needs a token both work through [build secrets](#build-secrets). 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 (walkthrough: [Browser and desktop](/docs/agents-api/desktop)). 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. > [!NOTE] > > 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. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt. I want to give my agent my own tools with a custom Docker image, and my own skills on top. Start the Dockerfile FROM ghcr.io/agent37-platform/hermes-base:latest, bake the tools into /usr/local/bin, publish it with npx agent37 templates build . --name my-custom-agent, point the agent at the managed model using AGENT37_LLM_PROXY_URL and AGENT37_MANAGED_TOKEN, create the instance with a budget, and install the skills into ~/.hermes/skills over exec once it is running. Done when the agent uses one of my tools in a chat turn. My key is in AGENT37_API_KEY. ``` ## 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, and its Dockerfile is public: [`images/b2b/hermes`](https://github.com/agent37-platform/templates/tree/main/images/b2b/hermes) (`--target base`) in [agent37-platform/templates](https://github.com/agent37-platform/templates). 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`, which is masked at runtime. Keep the base `ENTRYPOINT`. See [the full contract](/docs/agents-api/templates#build-on-the-hermes-base-image). > [!NOTE] > > **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://sandbox.agent37.app/v1/instances//exec \ > -H "X-Agent37-Key: $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`. > [!TIP] > > 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 [registry path](#import-a-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. > [!NOTE] > > 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 registry yet, or you would rather not run one. 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 **1 GB**; 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 up to three at once 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). 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). ### Build secrets A `RUN` step that needs a credential, cloning a private repository or installing from a private package index, takes it as a build secret. The value is available at `/run/secrets/` for that step only, is never written into the image, and is not stored by Agent37. ```dockerfile theme={null} FROM ghcr.io/agent37-platform/hermes-base:latest RUN --mount=type=secret,id=GITHUB_TOKEN \ git clone "https://x-access-token:$(cat /run/secrets/GITHUB_TOKEN)@github.com/acme/private-repo" /opt/app ``` ```bash theme={null} export GITHUB_TOKEN=ghp_... npx agent37 templates build . --name my-custom-agent --secret id=GITHUB_TOKEN ``` `--secret id=NAME` reads the value from the environment variable `NAME`; `id=NAME,env=VAR` reads a different variable and `id=NAME,src=PATH` reads a file, the same grammar as `docker build --secret`. Pass the flag once per secret. A `FROM` on a private registry takes the login the same way, and it is not stored either: ```bash theme={null} npx agent37 templates build . --name my-custom-agent \ --registry-auth registry.acme.com=bot:$REGISTRY_PASSWORD ``` Without the CLI, both go in the body of the start call; see [the raw contract](/docs/agents-api/templates#3-start-the-build). ### Import a registry image Push the image to any 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, either make the package public after the first push (**Packages** → **Package settings** → **Change visibility** → **Public**) or keep it private and register it with `registry_auth` (a personal access token with `read:packages` as the password). 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" }' ``` For a private registry, add the login beside the reference: ```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\": \"registry.acme.com/my-agent:v1\", \"registry_auth\": { \"username\": \"bot\", \"password\": \"$REGISTRY_PASSWORD\" } }" ``` Agent37 needs access only while it copies the image, and does not keep the login. The template and its instances no longer depend on the source registry after registration. A future image update needs a new tag, sent with `registry_auth` again if the registry is private. > [!TIP] > > 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. Creation does not wait for it: use [health](/docs/agents-api/health) to know when your service is answering. 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://sandbox.agent37.app/v1/instances//exec \ -H "X-Agent37-Key: $AGENT37_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "command": "your-cli --version" }' ``` > [!NOTE] > > 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, a Composio MCP server, and a pay-per-call paid-tools server in its environment, as `AGENT37_MANAGED_TOKEN`, `AGENT37_LLM_PROXY_URL`, `AGENT37_COMPOSIO_MCP_URL`, and `AGENT37_PERFLO_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. > [!WARNING] > > **Moving an existing instance onto your image? Its config already holds a dead token.** An > instance that previously ran a stock template (`agent37-hermes` and friends) has managed > entries, the Composio and paid-tools ones included, written into its persistent > `~/.hermes/config.yaml` with > the token as a literal. `AGENT37_MANAGED_TOKEN` is [reissued on every > restart](/docs/agents-api/managed-services#wiring-an-agent-to-both), stock images rewrite the file > each boot, and your image is now the one that has to: otherwise those entries start returning > `401` at the first restart. Rewrite them from env on **every** boot, unconditionally: > > ```bash entrypoint.sh theme={null} > python3 - <<'PY' > import os, yaml > path = os.path.expanduser("~/.hermes/config.yaml") > token = os.environ.get("AGENT37_MANAGED_TOKEN") > urls = {"composio": os.environ.get("AGENT37_COMPOSIO_MCP_URL"), > "perflo": os.environ.get("AGENT37_PERFLO_MCP_URL")} > try: > cfg = yaml.safe_load(open(path)) or {} > except FileNotFoundError: > cfg = {} > servers = cfg.get("mcp_servers", {}) > for name, url in urls.items(): > if token and url and name in servers: > servers[name] = {"url": url, "headers": {"Authorization": f"Bearer {token}"}} > if servers: > cfg["mcp_servers"] = servers > yaml.safe_dump(cfg, open(path, "w")) > PY > ``` > > The same applies to any other place a literal token landed, such as a `custom_providers` > entry pointing at `AGENT37_LLM_PROXY_URL`. ### 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. > [!TIP] > > 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` | Three builds run at a time per workspace. Wait for one 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. For a private registry, pass `registry_auth` with a username and password that can pull the image; the message says which was missing or refused. | | `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. | # Browser and desktop Source: https://www.agent37.com/docs/agents-api/desktop Browser use and computer use on Agent37: the stock agent drives a headless browser, and one cloud build gives it a full desktop you can watch live and take over. The stock `agent37-hermes` template includes a headless browser: the agent opens pages, clicks, fills forms, and reads what comes back. Build the desktop recipe and the same browser runs on a screen you can open in a tab, watch live, and take control of when a site needs a human. Other templates include different tools; a browser is not installed on every instance. | You want | Use | | - | - | | The agent to work on websites on its own | `agent37-hermes`, nothing to set up | | To watch it work, and take over the mouse and keyboard | The [desktop template](#add-a-desktop), one cloud build | | A full Linux desktop for my own automation harness, plus Docker | [`agent37-ubuntu` with `"type": "vm"`](/docs/agents-api/vm-instances), including XFCE and Firefox on port `6080` | | An Android phone with ADB and a live screen | [`agent37-android` with `"type": "vm"`](/docs/agents-api/android) | On the Ubuntu VM, your harness can install Chrome and its own screenshot, click, typing, and recording tools. Agent37 provides the machine, desktop, exec, and SSH; it does not bundle a general desktop-control SDK or session recording service. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt. I want to watch my agent use a browser and take over when it needs me. Build https://github.com/agent37-platform/examples/tree/main/custom-images/hermes-vnc-desktop with npx agent37 templates build . --name hermes-vnc-desktop --default-port 3737, create an instance from that template with a budget, mint a signed URL for port 6901, and open it at /vnc.html. Done when I can see the agent's browser move while it answers a chat turn. My key is in AGENT37_API_KEY. ``` ## Browsing with no screen Nothing to configure: `agent37-hermes` ships Chromium and a browser tool, so "book me a table" or "pull the pricing off these five sites" works from the first chat turn. See [Chat](/docs/agents-api/chat). To run that browser on Browser Use Cloud for stealth and residential proxies, see [Browser Use](/docs/agents-api/browser-use). The agent also has the rest of the machine. It can install software with `apt-get`, run scripts, and serve its own ports, as itself or through [exec](/docs/agents-api/exec) as root: ```bash curl theme={null} curl -X POST https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "command": "apt-get update && apt-get install -y ffmpeg", "user": "root" }' ``` What a headless browser can't do is show you what happened, or let you finish a step it can't: a sign-in, a code texted to your phone, a CAPTCHA. That is what the desktop is for. ## Add a desktop [hermes-vnc-desktop](https://github.com/agent37-platform/examples/tree/main/custom-images/hermes-vnc-desktop) is the stock Hermes image plus a view: a visible Chromium that the agent's browser tool drives, and [noVNC](https://github.com/novnc/noVNC) serving that screen on port `6901`. Everything the stock template does still happens, because the recipe wraps the stock entrypoint rather than replacing it: the [managed model](/docs/agents-api/managed-services), [app connections](/docs/agents-api/integrations), web search, and the `agent37` CLI the agent uses to [schedule itself](/docs/agents-api/crons#your-agent-can-schedule-itself). Build it into a [workspace template](/docs/agents-api/templates) once. The build runs on Agent37, so you don't need Docker: ```bash theme={null} git clone https://github.com/agent37-platform/examples cd examples/custom-images/hermes-vnc-desktop export AGENT37_API_KEY=sk_live_... npx agent37 templates build . --name hermes-vnc-desktop --default-port 3737 ``` Then create instances from the name, exactly as you would from a system template: ```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": "hermes-vnc-desktop", "budget": { "monthly_cap_micros": 5000000 }, "auto_sleep": true }' ``` `--default-port 3737` makes the create wait for the gateway, so the instance is ready to chat when it returns. The desktop adds nothing to the bill: a running instance is priced by its [shape](/docs/agents-api/instances), not by what runs inside. > [!TIP] > > Tell the agent it has an audience. Add a line to its instructions saying the user can see its screen, so it browses in the visible browser and asks the user to take over on a sign-in instead of asking for a password in chat. ## Open the desktop Mint a [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls) for port `6901` and change the path from `/` to `/vnc.html`, keeping the token: ```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": 6901, "ttl_seconds": 300 }' ``` ```text theme={null} https://ab12cd34ef-6901.agent37.app/vnc.html?a37_token=6abc...e1f0&autoconnect=1&resize=scale ``` Open that in a top-level tab. Add `&view_only=1` to watch without controlling. Ask the agent to browse something and watch it work. ## Embed it in your own app Load the noVNC client in your own page and connect its WebSocket straight to the instance, with the signed token in the query string. That connection needs no cookie, so it works from your origin in every browser. Your server mints the token for the signed-in user's own instance and hands the browser only a WebSocket URL: ```javascript node theme={null} app.post("/api/computer", async (req, res) => { const { instanceId } = await db.users.get(req.session.userId); // their own instance only const r = await fetch(`https://api.agent37.com/v1/instances/${instanceId}/signed-url`, { method: "POST", headers: { Authorization: `Bearer ${process.env.AGENT37_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ port: 6901, ttl_seconds: 60 }), }); if (!r.ok) return res.status(r.status).json(await r.json()); const signed = new URL((await r.json()).url); res.json({ ws: `wss://${signed.host}/websockify?a37_token=${signed.searchParams.get("a37_token")}` }); }); ``` noVNC is plain ES modules, so the page imports a pinned release from a CDN, with nothing to install and no build step. Start in view-only mode. **Take over** cancels the turn in flight, so you and the agent never fight over the mouse, and turns view-only off; **Return control** turns it back on: ```html theme={null}
Your agent has control ``` You and the agent share one browser, so the page you leave open is the one it sees next. Tell it so: send a line of context with the first message after a takeover, saying the user used the computer and it should look at the browser before carrying on. > [!WARNING] > > Don't iframe the signed URL itself. Its auth rides a `SameSite=Lax` cookie, which a cross-site frame does not send, so noVNC's sub-resources come back `401`. Connecting noVNC to the WebSocket, as above, needs no cookie. If you would rather frame a page, serve the instance from a [custom domain](/docs/agents-api/domains) on your own registrable domain, or reverse-proxy port `6901` through your own server with the `X-Agent37-Key` header attached. ### About the token * **It grants full control.** `viewOnly` is a setting in your page, not a permission: anyone holding the token can connect a VNC client that clicks and types. Mint it only for the instance's owner. * **It cannot be revoked.** Keep `ttl_seconds` at `60`, the minimum. The token only has to be valid when the socket opens, an open socket keeps working after it expires, and every reconnect mints a fresh one. * **Opening it wakes a sleeping instance.** The edge holds the connection while the instance restores, and the screen comes back as the agent left it. ## Worth knowing * **The login survives.** Chromium keeps a persistent profile on the instance's home volume, so a sign-in you finish during a takeover stays signed in across restarts, updates, and sleep, and the agent's browser uses the same profile. * **[Auto-sleep](/docs/agents-api/instance-auto-sleep) works.** The desktop and the visible Chromium survive the checkpoint and restore, with the page the agent left open. Connect the view only while it is on screen: it streams even when nothing changes, and that traffic counts as activity, so an open view keeps the instance awake. * **Give it room.** The default 2 vCPU / 4 GB works; use 4 / 8 if the agent opens heavy pages. * **Ports.** `6901` serves noVNC. VNC (`5900`) and DevTools (`9222`) stay on loopback inside the instance. * **Screen size** is 1440x900. Add `ENV AGENT37_SCREEN_GEOMETRY=1920x1080x24` to the Dockerfile for another size. * **Crons** work as on `agent37-hermes`, with one difference: on a workspace template, set `"agent": "hermes"` when you create one, or the run records its `session_id` only once the turn finishes. See [Crons](/docs/agents-api/crons). * **Telegram** webhook ports are wired at create on `agent37-hermes` only, so a Telegram bot on this template polls and needs `auto_sleep` off. See [Public ports](/docs/agents-api/public-ports). A full app built on this is [Build a proactive agent like Dots](/docs/agents-api/dots): a personal agent with its computer beside the chat. # 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 wrap 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 wrap theme={null} import requests response = requests.post( "https://api.agent37.com/v1/domains", headers={ "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }, json={ "domain": "acme-agents.com" }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = 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" }), }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json response wrap 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 } ``` **domain** `string` The domain you registered, lowercased. What your workspace serves lives one label below it (`ab12cd34ef.acme-agents.com`). **status** `string` `pending` until verification passes, then `active`. Only an active domain routes. **dns** `object` The nameservers to set at your domain's registrar: replace the domain's existing nameservers with the two in `dns.nameservers`. **created** `integer` Unix seconds when the domain was registered. Custom domains are a paid feature: they unlock once your workspace's top-ups total \$50, 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 wrap theme={null} curl -X POST \ https://api.agent37.com/v1/domains/acme-agents.com/verify \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.post( "https://api.agent37.com/v1/domains/acme-agents.com/verify", headers={ "Authorization": "Bearer sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/domains/acme-agents.com/verify", { method: "POST", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json response wrap 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 wrap theme={null} curl https://ab12cd34ef.acme-agents.com/v1/health \ -H "X-Agent37-Key: sk_live_..." ``` ```json response wrap 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 wrap 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. ### List domains ```bash curl wrap theme={null} curl -X GET \ https://api.agent37.com/v1/domains \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.get( "https://api.agent37.com/v1/domains", headers={ "Authorization": "Bearer sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/domains", { method: "GET", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ### Read a domain ```bash curl wrap theme={null} curl -X GET \ https://api.agent37.com/v1/domains/acme-agents.com \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.get( "https://api.agent37.com/v1/domains/acme-agents.com", headers={ "Authorization": "Bearer sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/domains/acme-agents.com", { method: "GET", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ### Delete a domain ```bash curl wrap theme={null} curl -X DELETE \ https://api.agent37.com/v1/domains/acme-agents.com \ -H "Authorization: Bearer sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.delete( "https://api.agent37.com/v1/domains/acme-agents.com", headers={ "Authorization": "Bearer sk_live_..." }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://api.agent37.com/v1/domains/acme-agents.com", { method: "DELETE", headers: { "Authorization": "Bearer sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json response wrap 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 \$50; 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. # Build a proactive agent like Dots Source: https://www.agent37.com/docs/agents-api/dots Give each user a named agent with its own computer that owns ongoing responsibilities, keeps its own schedule, and messages them first. OpenAI launched [Dots](https://openai.com/index/introducing-dots/) on September 29, 2026: an always-on agent in ChatGPT with its own cloud computer, the first one included with a Pro or Business Premium plan. You hand it a responsibility instead of a prompt, and it keeps working between conversations, decides when to check back, and messages you when something needs you. This guide builds the same loop on Agent37: one instance per user, a persona that tells the agent it may schedule itself, platform [crons](/docs/agents-api/crons) that wake it, and a callback into your app for the messages it sends first. ```text title="Paste this into your coding agent" wrap theme={null} Read https://www.agent37.com/docs/llms-full.txt. I want a Dots-style personal agent: each user names an agent that owns ongoing responsibilities, keeps its own schedule, and messages them first. Create one instance per user with POST /v1/instances (template agent37-hermes, budget.monthly_cap_micros, auto_sleep, a callback token in env and its SHA-256 in metadata), write ~/.hermes/SOUL.md read-merge-write so the agent schedules its own check-ins with the agent37 cron CLI and messages the user through a small script that calls my server, connect apps with /v1/instances/{id}/integrations, and show Scheduled and Completed from /v1/instances/{id}/crons. Done when the agent schedules a reminder by itself, the reminder fires, and my app shows the message it sent first. My key is in AGENT37_API_KEY. ``` > [card] [dots: this guide as a working app](https://github.com/agent37-platform/examples/tree/main/dots) > > Everything on this page, runnable: name an agent and pick a mascot and color, connect apps, chat with a live status line, watch Scheduled and Completed fill up as it schedules itself, get its first messages as notifications, and, with the desktop template, watch its computer and take it over. Express plus vanilla JS, no build step. Clone it, add your key, `npm start`. ![The dots example app: a chat where the agent messages the user first, and its Scheduled tasks panel](https://mintcdn.com/agent37/vM1PvO9h5WwOxETz/images/dots-app.webp?fit=max\&auto=format\&n=vM1PvO9h5WwOxETz\&q=85\&s=253f6f8f03e90f4fe075d72d467a5c02) The dots example: Pip texts first, and schedules its own tasks. Walkthrough: set up Pip, hand it a responsibility, and watch it schedule itself, message you first, and use its computer. ## One computer per user, and it keeps its own schedule Each user's agent is one [instance](/docs/agents-api/instances): a persistent computer running Hermes, with its own disk, memory, and connected apps. Three things turn it from a chat app into something that owns work: 1. **It schedules itself.** Every Agent37 agent image ships the `agent37 cron` CLI, which creates ordinary platform crons with the credential the instance already holds. A cron fires whether the instance is awake or asleep, so the agent can sleep between check-ins and still show up on time. 2. **Its persona says it may.** The gateway tells the agent it cannot follow up once a reply ends. That is true of a single reply, so the persona you write into `~/.hermes/SOUL.md` says how it does follow up: a scheduled check-in. 3. **It can reach the user.** A check-in runs in a fresh session that nobody is watching. The agent calls your server with a token you planted at create, and your app shows the message. **1. Create the user's agent** [`POST /v1/instances`](/docs/agents-api/instances/create) on the `agent37-hermes` template, with four things beyond the usual: a monthly `budget` (the agent spends on its own schedule, so a cap that resets suits it better than one-time credit), `auto_sleep` so it bills disk alone between check-ins, a random callback token in `env`, and that token's SHA-256 in `metadata`. ```bash curl theme={null} curl https://api.agent37.com/v1/instances \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "template": "agent37-hermes", "name": "dots-Pip", "budget": { "monthly_cap_micros": 5000000 }, "auto_sleep": true, "idle_timeout_seconds": 1800, "env": { "DOTS_CALLBACK_TOKEN": "f3a9..." }, "metadata": { "dots_callback_token_sha256": "9c1e..." } }' ``` ```javascript node theme={null} const token = crypto.randomBytes(24).toString("hex"); 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({ template: "agent37-hermes", name: "dots-Pip", budget: { monthly_cap_micros: 5_000_000 }, auto_sleep: true, idle_timeout_seconds: 1800, env: { DOTS_CALLBACK_TOKEN: token }, metadata: { dots_callback_token_sha256: crypto.createHash("sha256").update(token).digest("hex"), }, }), })).json(); await db.users.update("u_882", { instanceId: inst.id }); ``` The call returns once the computer is up. Poll [`GET /v1/health`](/docs/agents-api/health) on the instance URL until it answers `"healthy": true` before writing files or sending the first message. `env` is write-only, so the raw token lives only inside the instance; the hash in `metadata` is what your server reads back later. The idle timeout sits above the longest turn you expect, because a turn whose browser went away can be checkpointed mid-run once the instance goes idle. **2. Give it a name and a job description** Hermes reads its persona from `~/.hermes/SOUL.md` on the instance's disk. A fresh instance already has one, so write it read-merge-write with the [Files API](/docs/agents-api/files): read the file, replace a block you own (marked so you can find it again when the user renames the agent), and write the whole file back. `PUT` replaces the file with the body you send, so never `PUT` a fragment. ```bash curl theme={null} # read curl -G https://ab12cd34ef.agent37.app/v1/files/content \ -H "X-Agent37-Key: sk_live_..." \ --data-urlencode "path=~/.hermes/SOUL.md" -o SOUL.md # edit SOUL.md locally, then write the whole file back curl -X PUT "https://ab12cd34ef.agent37.app/v1/files/content?path=~/.hermes/SOUL.md" \ -H "X-Agent37-Key: sk_live_..." \ --data-binary @SOUL.md ``` ```javascript node theme={null} const url = (path) => `https://${inst.id}.agent37.app/v1/files/content?path=${encodeURIComponent(path)}`; const H = { "X-Agent37-Key": process.env.AGENT37_API_KEY }; const current = await (await fetch(url("~/.hermes/SOUL.md"), { headers: H })).text(); const start = current.indexOf(""); const end = current.indexOf(""); const rest = start === -1 ? current : current.slice(0, start) + current.slice(end + "".length); await fetch(url("~/.hermes/SOUL.md"), { method: "PUT", headers: H, body: `${personaBlock(user)}\n\n${rest.trim()}\n`, }); ``` The persona is where the product lives. The part that makes it own work: ```text the persona block, abridged theme={null} # Pip You are Pip, Sam's personal agent. You have your own computer, and it is always there. ## You own ongoing responsibilities 1. Keep it on your list: one line per responsibility in ~/.dots/responsibilities.md, "- Title :: what you are doing now". Delete the line when it is done. 2. Keep your own schedule: agent37 cron add --name "..." --schedule "0 9 * * *" --timezone America/Los_Angeles --prompt "...". Sam's timezone is America/Los_Angeles: pass it on every cron and work out times there (TZ=America/Los_Angeles date). Each check-in wakes you in a fresh chat that holds only its prompt, so write the prompt to stand on its own. For a one-time follow-up, pin the exact minute, hour, day and month; the app deletes it once it has fired. Start a yearly one's --name with "Yearly". The note that you cannot follow up after a reply ends is about that single reply; a scheduled check-in is how you do. 3. Message first: when a check-in finds something Sam should know, run sh ~/.dots/notify "your message". Stay quiet when there is nothing new. ## Hand back what is not yours to do Ask before spending money, sending anything to other people, or changing an account. Never make purchases: find the options, then hand the checkout back to Sam. ``` Seed what you already know about the user into `~/.hermes/memories/USER.md` the same way (entries are separated by a line holding a single `§`). The app shows `~/.dots/responsibilities.md` as the agent's In progress list. **3. Connect their apps** Onboarding offers the user's apps next, through [managed Composio](/docs/agents-api/integrations) on their own instance: list the catalog, start a connection with a `callbackUrl` back into your app, and poll the connections list until the new account reads `ACTIVE`. ```bash curl theme={null} curl "https://api.agent37.com/v1/instances/ab12cd34ef/integrations/toolkits?limit=24" \ -H "Authorization: Bearer sk_live_..." curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connect \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "toolkit": "googlecalendar", "callbackUrl": "https://your-app.com/connected" }' curl https://api.agent37.com/v1/instances/ab12cd34ef/integrations/connections \ -H "Authorization: Bearer sk_live_..." ``` ```javascript node theme={null} const { redirectUrl, connectedAccountId } = await (await fetch( `https://api.agent37.com/v1/instances/${inst.id}/integrations/connect`, { method: "POST", headers: { Authorization: `Bearer ${process.env.AGENT37_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ toolkit: "googlecalendar", callbackUrl: "https://your-app.com/connected" }), } )).json(); // Send the browser to redirectUrl, then poll GET .../integrations/connections // until the entry with id === connectedAccountId has status "ACTIVE". ``` Open `redirectUrl` in a tab you opened during the click itself; a window opened after an `await` is blocked as a popup. The catalog includes no-auth entries (`isNoAuth: true`) that need no connect step, so filter those out of an onboarding grid. **4. Let it introduce itself** Dots speaks first. Send the first turn yourself, with an app brief as the input, and hide it when you render history. The gateway has no system-prompt field on [`POST /v1/responses`](/docs/agents-api/chat), so app context rides as a marked preamble: ```text the hidden first turn theme={null} App context (from the Dots app, not your person; they do not see this part): This is your very first conversation with Sam. They just created you and named you Pip. Introduce yourself in two or three short sentences, in your own voice. If any apps are connected, take a quick read-only look to learn what their days look like. Then suggest three concrete things you could take off their plate, at least one of them ongoing. Ask which they want. End of app context. ``` When you render a session, drop user messages that start with the marker, or show only the text after `End of app context.` when the user's own words follow it. Store the session id the first stream event returns; see the next step. **5. Stream chat with a live status line** Send turns with `stream: true` through your server (the key never reaches the browser). The [stream's](/docs/agents-api/streaming) tool events are what make the agent feel present: map `response.tool_call.started` to a status line under its name and to rows in an Activity panel, and give the running row a stop button that calls `POST /v1/responses/{id}/cancel`. ```javascript node theme={null} switch (event) { case "response.created": // { id, session_id }: record the thread, arm the stop button status("Thinking..."); break; case "response.tool_call.started": // { tool, label } if (/agent37 cron/.test(data.label)) status("Keeping its schedule..."); else if (/\.dots\/notify/.test(data.label)) status("Messaging you..."); else status(`${verbFor(data.tool)}: ${data.label ?? ""}`); break; case "response.output_text.delta": status("Typing..."); break; } ``` When the agent schedules itself, its terminal tool call carries the `agent37 cron add ...` command as its `label`, so the status line can say so. Keep your own index of the sessions your app starts, recorded from `response.created`: [`GET /v1/sessions`](/docs/agents-api/sessions) returns only the 100 most recent, and every cron firing opens one, so a busy schedule pushes chats out of that list. **6. Show what it has scheduled, and what already ran** Everything the agent scheduled for itself is an ordinary cron, so the profile's Scheduled and Completed tabs are two reads. Your app can add tasks the same way, and tell the two apart by remembering the ids it created. ```bash curl theme={null} # Scheduled curl https://api.agent37.com/v1/instances/ab12cd34ef/crons \ -H "Authorization: Bearer sk_live_..." # Completed: each run's session_id is the chat the check-in ran in curl https://api.agent37.com/v1/instances/ab12cd34ef/crons/3ff3e1104cea/runs \ -H "Authorization: Bearer sk_live_..." # Run now curl -X POST https://api.agent37.com/v1/instances/ab12cd34ef/crons/3ff3e1104cea/run \ -H "Authorization: Bearer sk_live_..." ``` ```javascript node theme={null} const H = { Authorization: `Bearer ${process.env.AGENT37_API_KEY}` }; const base = `https://api.agent37.com/v1/instances/${inst.id}/crons`; const { data: crons } = await (await fetch(base, { headers: H })).json(); const scheduled = crons.map((c) => ({ ...c, setBy: myCronIds.has(c.id) ? "you" : "agent" })); const runs = (await Promise.all(crons.map(async (c) => (await (await fetch(`${base}/${c.id}/runs`, { headers: H })).json()).data .map((run) => ({ ...run, name: c.name })) ))).flat().sort((a, b) => b.ran_at - a.ran_at); ``` ```json a cron the agent created for itself theme={null} { "id": "3ff3e1104cea", "name": "Weekday Hacker News top story", "agent": null, "prompt": "Find the current top story on Hacker News ... Send Sam one concise line with the story title, a factual summary, and a link using: sh ~/.dots/notify \"...\". If the story cannot be verified, do not invent details; notify Sam briefly that the lookup failed.", "schedule": "0 9 * * 1-5", "timezone": "America/Los_Angeles", "enabled": true, "last_run": null, "next_run": 1790784000, "created": 1790735848 } ``` Pause is a `PATCH` of every enabled cron to `{ "enabled": false }`, and Resume turns back on the ones Pause turned off. Chat still works while paused, so the agent can add a cron; the example turns those off too, each time it reads the schedule and after every turn. A task your app adds is sent to the agent verbatim when it fires, with nothing that says nobody is watching that chat, so put the delivery instruction in the prompt itself: "Sam is not watching this chat: send the result with `sh ~/.dots/notify`." **7. Let it message you first** Setup writes a small script to the instance that posts to your server with the planted token. `AGENT37_INSTANCE_ID` is set by the platform in every container, so the agent never has to know which instance it is: ```bash ~/.dots/notify theme={null} #!/bin/sh body=$(node -e 'process.stdout.write(JSON.stringify({ instance_id: process.env.AGENT37_INSTANCE_ID, text: process.argv[1] || "" }))' "$1") curl -sS -X POST https://your-app.com/api/notify \ -H "Authorization: Bearer $DOTS_CALLBACK_TOKEN" \ -H "Content-Type: application/json" -d "$body" ``` Encoding the JSON in `node` keeps quotes and newlines in the agent's message from breaking the request. Your endpoint verifies the token against the instance's metadata before it stores anything: ```javascript node theme={null} app.post("/api/notify", async (req, res) => { const token = (req.headers.authorization || "").replace(/^Bearer\s+/i, ""); const { instance_id, text } = req.body; if (!/^[a-z0-9]{10}$/.test(instance_id ?? "")) return res.status(400).json({ error: "bad_request" }); // Owner first: an id none of your users owns never reaches the Sandbox API. const user = await db.users.findByInstance(instance_id); if (!user) return res.status(403).json({ error: "forbidden" }); const inst = await (await fetch(`https://api.agent37.com/v1/instances/${instance_id}`, { headers: { Authorization: `Bearer ${process.env.AGENT37_API_KEY}` }, })).json(); const expected = Buffer.from(inst.metadata?.dots_callback_token_sha256 ?? ""); const presented = Buffer.from(crypto.createHash("sha256").update(token).digest("hex")); if (expected.length !== presented.length || !crypto.timingSafeEqual(expected, presented)) { return res.status(403).json({ error: "forbidden" }); } await db.notifications.create({ userId: user.id, text, at: Date.now() }); res.json({ ok: true }); }); ``` Show it as a new message with a badge and a toast. When the user replies, include the message in the reply's app context, since it came from a check-in session and the chat they are replying in has never seen it. This endpoint is also where Web Push, email, or a text would go when no tab is open. **8. Show and edit its memory** Dots keeps its memory private; yours can show it. Hermes keeps notes about the user in `~/.hermes/memories/USER.md` and its own in `MEMORY.md`. List the folder for each file's `modified`, read the content, and save edits with `X-Expected-Mtime` so a note the agent wrote in the meantime is never silently overwritten: ```bash curl theme={null} curl -G https://ab12cd34ef.agent37.app/v1/files \ -H "X-Agent37-Key: sk_live_..." \ --data-urlencode "path=~/.hermes/memories" # -> entries[].modified, e.g. 1790736851657.1233 curl -X PUT "https://ab12cd34ef.agent37.app/v1/files/content?path=~/.hermes/memories/USER.md" \ -H "X-Agent37-Key: sk_live_..." \ -H "X-Expected-Mtime: 1790736851657.1233" \ --data-binary @USER.md ``` ```javascript node theme={null} const res = await fetch( `https://${inst.id}.agent37.app/v1/files/content?path=${encodeURIComponent("~/.hermes/memories/USER.md")}`, { method: "PUT", headers: { "X-Agent37-Key": process.env.AGENT37_API_KEY, "X-Expected-Mtime": String(entry.modified), }, body: entries.join("\n§\n"), } ); if (res.status === 412) { // The agent wrote the file since you read it: reload and show its version. } ``` Send `modified` back exactly as the listing returned it. It has a fractional part, and a rounded value never matches, so every write fails with `412 modified`. ## Watch and take over its computer Dots puts its computer beside the chat: you watch it work, and when a site needs you (a sign-in, a code sent to your phone, a CAPTCHA), you take over the mouse and keyboard, then return control. The stock `agent37-hermes` browser is headless, so this needs an image with a screen: see [Browser and desktop](/docs/agents-api/desktop). The example turns it on when `DESKTOP_TEMPLATE` is set in its `.env`, and runs exactly as above without it. **1. Build the desktop template** Build the [hermes-vnc-desktop](/docs/agents-api/desktop#add-a-desktop) recipe into a workspace template once. It is the stock Hermes image plus a visible Chromium, which the agent's browser tool drives, and noVNC serving that screen on port `6901`. The build runs in the cloud, so you don't need Docker: ```bash theme={null} git clone https://github.com/agent37-platform/examples cd examples/custom-images/hermes-vnc-desktop AGENT37_API_KEY=sk_live_... npx agent37 templates build . --name hermes-vnc-desktop --default-port 3737 ``` Then set `DESKTOP_TEMPLATE=hermes-vnc-desktop` in the example's `.env`. Each new user's agent is created with the call from **Create the user's agent**, with `"template": "hermes-vnc-desktop"` in place of `agent37-hermes`; everything else on this page works unchanged. Add a section to the persona so the agent knows the user can see its screen: browse in the visible browser, and when a site needs the user, ask them to take over instead of asking for a password in chat. **2. Mint a token for each connection** Your server mints a [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls) for port `6901` of the user's own instance and hands the browser only a WebSocket URL built from it. The token rides in that URL's query string, so the connection needs no cookie and works from your own origin: the browser connects straight to the instance from your page. ```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": 6901, "ttl_seconds": 60 }' ``` ```javascript node theme={null} app.post("/api/computer", async (req, res) => { const { instanceId } = await db.users.get(req.session.userId); // their own instance only const r = await fetch(`https://api.agent37.com/v1/instances/${instanceId}/signed-url`, { method: "POST", headers: { Authorization: `Bearer ${process.env.AGENT37_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ port: 6901, ttl_seconds: 60 }), }); if (!r.ok) return res.status(r.status).json(await r.json()); const signed = new URL((await r.json()).url); res.json({ ws: `wss://${signed.host}/websockify?a37_token=${signed.searchParams.get("a37_token")}` }); }); ``` ```json response theme={null} { "url": "https://ab12cd34ef-6901.agent37.app/?a37_token=6abc...e1f0", "domain_urls": [], "port": 6901, "expires_at": 1790739708 } ``` The token grants full control, whatever your page does with it, and it cannot be revoked. Mint it only for the instance's owner, and keep it at `60` seconds, the minimum: it only has to be valid when the socket opens, an open socket keeps working after it expires, and every reconnect mints a fresh one. **3. Show the screen, take over, return control** [noVNC](https://github.com/novnc/noVNC) draws the screen. It is plain ES modules, so the page imports a pinned release straight from a CDN, with nothing to install and no build step. Start in view-only mode. **Take over** cancels the turn in flight, so the two of you never fight over the mouse, and turns view-only off; **Return control** turns it back on: ```html theme={null}
Pip has control ``` You and the agent share one browser, so the page you leave open is the one it sees next. The example tells it so: the first message after a takeover carries a line of app context saying you used the computer and it should look at the browser before carrying on. Connect the view only while it is on screen. It streams even when nothing on the screen changes, and that traffic counts as activity, so an open view keeps an [auto-sleep](/docs/agents-api/instance-auto-sleep) instance awake. The example closes the socket when the tab is hidden or the pane is closed, and opening it again wakes the instance: from asleep, the screen was back in 4 to 18 seconds in testing. On a workspace template, a [cron](/docs/agents-api/crons) that names no agent records its run's `session_id` only once the turn finishes. Name it, `"agent": "hermes"`, and the run links its chat from the moment it fires, so your app can open a check-in while it is still working. Add it to the tasks your app creates. `agent37 cron add` has no flag for it, so `PATCH` the crons the agent schedules for itself with `{ "agent": "hermes" }`; the example does that after every turn. Don't put the signed URL itself in an iframe on your site: it authenticates with a `SameSite=Lax` cookie, which a cross-site frame does not send. Connecting noVNC to the WebSocket, as above, needs no cookie. ## Reset Reset is [`DELETE /v1/instances/{id}`](/docs/agents-api/instances/delete) followed by a fresh create from onboarding. Delete is permanent: the computer, its files and memory, sessions, and crons go with it, and billing for it ends. ## Worth knowing * **Cost per user.** The smallest shape is \$4.76 per month if it never sleeps. With [auto-sleep](/docs/agents-api/instance-auto-sleep) it bills disk alone while asleep, and crons and your messages wake it, so an agent that works a few minutes at a time costs a fraction of that. Managed LLM, search, and app calls draw the wallet up to each instance's [budget](/docs/agents-api/budgets). * **One user, one instance.** Your [instance limit](/docs/agents-api/billing#instance-limits) caps how many users' instances are awake at once, never how many users you have; it rises as you top up. * **There is no one-shot cron.** A follow-up is a cron pinned to one date and time (`40 21 29 9 *`). It fires again a year later and holds one of the instance's 50 cron slots until it is deleted, and asking the agent to delete it in the check-in is not reliable. The example deletes it itself: whenever it reads the schedule, a cron the agent pinned to one date whose `last_run` is set has fired, so it goes. `last_run` is set only by a scheduled firing, never by **Run now**. The persona has the agent start a yearly one's name with "Yearly" so it stays. * **A deleted cron takes its history with it.** Read `GET /v1/instances/{id}/crons/{cronId}/runs` and keep the runs before you delete a fired reminder, so **Completed** still shows it and opens its session. * **A cron keeps its own timezone.** Show a cron pinned to one date from its `next_run` in the user's timezone, and name the zone next to a recurring one that is not theirs. The persona asks for the user's timezone on every cron, and the agent works out times with `TZ=... date`. * **Crons are fire and forget.** A run's `status: "triggered"` means the turn started. What the agent did is in the session it names. * **Messages arrive only while a tab is open** unless your notify endpoint pushes them somewhere. Agent37 has no push channel of its own. * **Purchases are handed back.** The persona has the agent gather options and return the checkout to the user; it never pays for anything. On the desktop template, the same goes for sign-ins: it asks the user to take over. * **Texting it** is its own guide: [Text your agent on iMessage](/docs/agents-api/imessage). Not affiliated with OpenAI. Dots is a product of OpenAI; this guide only borrows the idea. # 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. > [!NOTE] > > Both planes take the same `sk_live_` key, as `Authorization: Bearer` on the Sandbox 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. ## Sandbox API errors Errors from `https://api.agent37.com/v1/*` always carry `code` and `message`, plus `detail` on the one error that has raw output worth showing: a `provisioning_failed` includes the tail of your container's console log, so a container the runtime could not start returns its own startup error. A container that starts and then exits is not a failed create: the instance is created and `running`, and the crash shows up in [logs](/docs/agents-api/logs) and in the `health` beside them. There is no `param` or `hint` on this plane. ```json wrap 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." } } ``` **error.code** `string` A stable, machine-readable identifier. Branch on this. **error.message** `string` A human-readable description. Safe to show, but do not parse it. **error.detail** `string` Optional. Raw failure output, present on a `provisioning_failed` wherever the platform got a verdict back from your container or its host: a failed create, and a failed lifecycle action (`start`, `restart`, `update`, `resize`, `restore`, or a host move). It is the last lines of your container's console log (host paths and IPs redacted, truncated to the final 2,000 characters). The console log is appended across boots, so lines from an earlier boot can appear above the failing boot's lines; read from the bottom. On those responses `error.message` is the classified reason too, and both persist on the instance as [`status_reason`](/docs/agents-api/instances/object), so the error body alone is enough to act on. ### Sandbox 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 registry reference the platform cannot pull (private without `registry_auth`, or a wrong login), starting a [template build](/docs/agents-api/templates#build-an-image-in-the-cloud) whose context is missing or over 1 GB or whose `secrets` or `registry_auth` body is malformed, a lifecycle action in the wrong state, `stop` on a sleeping instance parked in cold storage (wake it with `start` or delete it), 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)), a [`restore`](/docs/agents-api/instances/restore) with a malformed body, a field other than `backup`, or on an instance that is not `running` or `stopped`. | | `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/409 | A write to a system template (system templates are read-only). As a 409 on [`POST /v1/members`](/docs/agents-api/members) it means the address is already a member or already invited, and on a member delete it means the workspace's last admin. | | `not_found` | 404 | No instance, template, [backup](/docs/agents-api/instances/list-backups), or [member](/docs/agents-api/members) 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. Also a template build or member invite from a workspace made by [`POST /v1/signup`](/docs/agents-api/signup) before its wallet is funded. | | `instance_limit_reached` | 409 | A create or start would take the workspace past its [instance limit](/docs/agents-api/billing#instance-limits): 10 instances running at once once you have topped up, 50 once top-ups total \$50, 200 once they total \$250, 1,000 once they total \$1,000, and one instance in total on the free credit. Sleeping and stopped instances never count past the free tier. Stop or sleep one, or 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 \$50. Free workspaces (before your first top-up) run any template on the 2 vCPU / 4 GB shape; a top-up unlocks the 4/8, 4/16, and 4/32 shapes. | | `capacity_unavailable` | 409 | `start` or `resize`: no host in the fleet can fit this instance, or the resize increase, right now. Also a wake (`start` or `exec` on a sleeping instance) that has to move the instance to another host when none has room. | | `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 three builds are already running in the workspace. Wait for one to finish and retry. | | `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/503 | A transient state conflict: the instance is mid-transition, for example `stop`, `start`, `exec` or `update` while a sleep checkpoint is being written, which takes tens of seconds on a large instance, or a `start`/`update` on an instance another request is already waking. Retry with backoff. As a 503 it means a backing service was briefly unavailable: [metrics](/docs/agents-api/metrics), or a [template build](/docs/agents-api/templates#build-an-image-in-the-cloud) `start` that could not get a build machine (that build is left `failed`, so retry on a new one). | | `rate_limited` | 429 | An on-demand [backup](/docs/agents-api/instances/create-backup) inside the 15-minute per-instance window, or while another backup is running on the same host. `Retry-After` says how many seconds to wait. | | `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), including a wake via `start`, `exec` or `update` that failed; after a failed wake the instance reads `sleeping` again, and after a failed [restore](/docs/agents-api/instances/restore) it reads `stopped`, or `running` when the restore never took the container down, with the reason in `status_reason` either way. A failed [restart](/docs/agents-api/instances/restart) likewise leaves the instance `running` unless its container is confirmed down, and an [update](/docs/agents-api/instances/update) that never reached the instance's host leaves a running instance `running`. A [backup](/docs/agents-api/instances/list-backups) list while backup storage is unreachable fails the same way rather than returning an empty list, and the instance is untouched. A failed create or lifecycle action carries the classified reason in `error.message` and your container's console tail in `error.detail`, the same pair the instance's `status_reason` keeps. Creates debit nothing, so a failed create costs nothing. | > [!NOTE] > > 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`, `try_again`, 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 wrap theme={null} { "error": { "code": "validation_error", "message": "goal mode is not yet supported on this gateway.", "param": "mode", "hint": "Use mode \"chat\"." } } ``` **error.param** `string` The request field that was invalid. Present on `validation_error` when a specific field is at fault; a malformed JSON body has no `param`. **error.hint** `string` A suggested next step, when one applies. **error.response\_id** `string` On `session_busy`: the id of the response already running on the session. Reattach with `GET /v1/responses/{id}/stream` or cancel it. Every `session_busy` from a current gateway carries it; treat it as optional only for instances still on an older gateway. ### 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 | A flood of simultaneous connections to one instance, far beyond what one user's agent produces. The instance is protected and your other instances are unaffected; retry in a moment. | | `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 | The instance went silent for 15 minutes, either before the response started or mid-stream (see [Long requests](/docs/agents-api/urls#long-requests)). Agent turns have no time limit of their own, since the gateway answers immediately and keeps the stream alive. The work keeps running inside the instance: retry, or read the result from its session. | | `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=` or `GET /v1/files/archive?path=` points at something that isn't a directory. | | `response_not_found` | 404 | No response with that id. | | `profile_not_found` | 404 | No profile with that name on the instance (`profile` on `POST /v1/responses`, `?profile=` on the session reads). | | `file_not_found` | 404 | No file or directory at that path (`GET /v1/files`, `GET /v1/files/archive`, `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; `error.response_id` names it (absent only on instances still on an older gateway). | | `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. | > [!NOTE] > > 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). On OpenClaw a turn that ends in error carries OpenClaw's own error kind as `error.code` (`refusal`, `timeout`, `context_length`, or `unknown`); a provider rate limit arrives as `rate_limited`, and when OpenClaw reports no kind the code is `agent_error`. Treat any code you do not recognize as an agent-side failure: log it and show `message`. > [!NOTE] > > Not every failed turn is an HTTP error. `POST /v1/responses` never rejects because the agent failed: once the turn is accepted, 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. `rate_limited`, `agent_error`, `agent_unavailable`, and the provider-specific codes reach you this way on a turn, never as their HTTP status; the statuses in the table apply when the same failure hits a session or model call. Check `status`, not just the HTTP code. See [Send a message](/docs/agents-api/chat) and [Streaming](/docs/agents-api/streaming). > [!NOTE] > > The BYO-account harnesses signal a missing account with `auth_error`. Until an account is connected, a turn comes back `status: "failed"` with `error.code` `auth_error` and a `hint`, and `GET /v1/health` reports `"healthy": false`. On [Claude Code](/docs/agents-api/claude-code#connect-your-claude-account) the hint says to connect a Claude account; on [Codex](/docs/agents-api/codex#connect-your-openai-account) it says to run `codex login --device-auth` or set `OPENAI_API_KEY`; on [Grok](/docs/agents-api/grok) it says to set `XAI_API_KEY` or run `grok login --device-code`. ## 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 wrap 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", }, ) body = r.json() if not r.ok or body.get("status") == "failed": error = body["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 wrap 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", }), }); const body = await res.json(); if (!res.ok || body.status === "failed") { const { error } = body; 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 Sandbox API: `insufficient_balance` sends your user to the billing dashboard, `no_capacity` is retryable, and `invalid_request` means fix the request before retrying. > [!TIP] > > `no_capacity` (503, Sandbox API) and `rate_limited` (429, Agent API) are safe to retry with backoff. So are `try_again` (409, Sandbox API) 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 > [accordion] insufficient\_balance (402): the workspace wallet is empty > > 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. That third case does not reach the Agent API as `insufficient_balance`: the refused LLM call fails the turn (`status: "failed"`, or a `response.failed` event) with `error.code` `quota_exhausted`, the same code a spent instance budget produces; the `message` names the workspace balance, so it tells the two apart. The fix is the same everywhere: top up the wallet at `https://www.agent37.com/dashboard/cloud/billing` (\$5 minimum, \$10,000 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). > [accordion] instance\_budget\_exhausted (402): this instance hit its budget > > 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 in an API error on either plane: it is what the managed endpoint returns to the agent's own call inside the instance. What you see is the failed turn (`status: "failed"`, or a `response.failed` event); on Hermes its `error.code` is `quota_exhausted`, with a hint naming the fix. > [accordion] session\_busy (409): one response at a time > > A session runs one response at a time. Posting new input while a turn is in flight returns this, 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` (a finished response just returns its terminal state), or start a fresh session by omitting `session_id`. If the field is absent (an instance still on an older gateway), read `active_response_id` from `GET /v1/sessions/{id}` instead. See [Sessions](/docs/agents-api/sessions). > [accordion] The capacity trio: instance\_limit\_reached, capacity\_unavailable, no\_capacity > > Three different walls, three different fixes. `instance_limit_reached` (409, create or start): the workspace already runs as many instances as its limit allows (10 at once once you have topped up, 50 once top-ups total \$50, 200 once they total \$250, 1,000 once they total \$1,000, one in total on the free credit); stop or sleep instances you are not using, 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). > [!NOTE] > > 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 you can watch and take over, most with a forkable repo. Every guide in this section ends with something running, and almost every one comes with a public repo. Each one opens with a prompt to paste into your coding agent. 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. Start with the sandbox capability or application you need. Agent hosting and chat apps are examples of what a template can add to an instance. | I want to… | Guide | Code | | - | - | - | | Create a sandbox, run a command, and access a service | [Quickstart](/docs/quickstart) | None | | Run my own tools, services, or harness in a custom Docker image | [Build a custom image](/docs/agents-api/custom-image) | [custom-agent-image](https://github.com/agent37-platform/custom-agent-image) | | Move the **Docker containers** I already run on my own servers, and stop paying for idle machines | [Move from Docker](/docs/agents-api/migrate-from-docker) | None | | Run a **full VM** with Docker-in-Docker, Compose, and a Linux desktop | [VM instances](/docs/agents-api/vm-instances) | [Ubuntu image](https://github.com/agent37-platform/templates/tree/main/images/vm/ubuntu) | | Drive an **Android 14 phone** with ADB and watch its screen in the browser | [Host an Android phone](/docs/agents-api/android) | [Android image](https://github.com/agent37-platform/templates/tree/main/images/vm/android) | | Host **Claude Code** on its own always-on instance, signed in to my own Anthropic account | [Host Claude Code](/docs/agents-api/claude-code) | None | | Host **Codex** on its own always-on instance, signed in to my own OpenAI account | [Host Codex](/docs/agents-api/codex) | None | | Connect an **OpenAI Agents API** session to a persistent sandbox, with OpenAI running the agent and Agent37 running its commands | [OpenAI Agents API on Agent37](/docs/agents-api/openai-agents) | None | | Give an **OpenAI Agents SDK** sandbox agent a remote shell and filesystem, on its own or inside a Temporal workflow | [OpenAI Agents SDK](/docs/agents-api/openai-agents-sdk) | None | | Run my **Cursor Cloud Agents** on an always-on machine I control, with Cursor running the agent and Agent37 running its commands and keeping the checkout | [Cursor Self-Hosted Machines on Agent37](/docs/agents-api/cursor) | None | | Give a **LangChain Deep Agent** a remote shell and filesystem | [LangChain Deep Agents](/docs/agents-api/langchain) | [langchain-agent37](https://github.com/agent37-platform/langchain-agent37) | | Send my first message through an agent template | [Send your first agent message](/docs/agents-api/agent-quickstart) | None | | 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) | | Let users ask the agent for a **website** and get a live public URL back | [Build a website builder](/docs/agents-api/site-builder) | [site-builder](https://github.com/agent37-platform/examples/tree/main/site-builder) | | Give every user a personal agent like OpenAI's **Dots**: it owns ongoing jobs, keeps its own schedule, messages them first, and lets them watch and take over its computer | [Build a proactive agent like Dots](/docs/agents-api/dots) | [dots](https://github.com/agent37-platform/examples/tree/main/dots) | | Build an assistant users **text on iMessage**, like Instinct: its own line, inbox, and calls, plus reminders that text them first | [Build a texting assistant like Instinct](/docs/agents-api/instinct) | [instinct](https://github.com/agent37-platform/examples/tree/main/instinct) | | Give every user a **team of named Bots** on one shared computer, like xAI's Grok Bot: parallel chats, routines, and a team chat | [Build an agent team like Grok Bot](/docs/agents-api/grok-bot) | [grok-bot](https://github.com/agent37-platform/examples/tree/main/grok-bot) | | Give every user a personal agent like Meta's **Muse**: daily ideas, goals it checks in on, and a library of what it made | [Build a personal assistant like Muse](/docs/agents-api/muse) | [muse](https://github.com/agent37-platform/examples/tree/main/muse) | | 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) | | Let my users message their agent from **Telegram, WhatsApp, Slack or Discord**, connected from my own UI | [Messaging channels](/docs/agents-api/messaging) | [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 my users' agents a **Browser Use** cloud browser (stealth, residential proxies, CAPTCHA solving) without putting my Browser Use key in their instance | [Browser Use](/docs/agents-api/browser-use) | [hermes-browser-use](https://github.com/agent37-platform/examples/tree/main/hermes-browser-use) | | Run the tools of my **Claude Managed Agents** sessions on an always-on instance I control, while Anthropic runs the agent loop | [Self-host Claude Managed Agents](/docs/agents-api/claude-managed-agents) | None | | Run Claude Code on **my own OpenRouter key**: override the Anthropic base URL, pick the model per role | [Bring your own model](/docs/agents-api/claude-code#bring-your-own-model) | [claude-code-byo-model](https://github.com/agent37-platform/examples/tree/main/claude-code-byo-model) | | Host **Grok** on its own always-on instance, on my own xAI API key | [Host Grok](/docs/agents-api/grok) | None | | Host **OpenCode** on its own always-on instance: works out of the box on the managed model, no account needed | [Host OpenCode](/docs/agents-api/opencode) | None | | Host **Pi**, the minimal open-source harness, on its own always-on instance: the managed model out of the box, or my own provider key or Claude/ChatGPT/Copilot subscription | [Host Pi](/docs/agents-api/pi) | None | | Host **n8n**, the workflow automation tool, on its own always-on instance: visual editor, webhooks, AI nodes on the managed model | [Host n8n](/docs/agents-api/n8n) | None | | Run an agent we have no template for, in my own image, 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 | | Let each user **text their agent on iMessage**, with its own email address and phone calls | [Text your agent on iMessage](/docs/agents-api/imessage) | [instinct](https://github.com/agent37-platform/examples/tree/main/instinct) | | Watch my agent's desktop **live**: a browser view of it clicking and typing, embeddable in my own app, and takeover when a site needs me | [Browser and desktop](/docs/agents-api/desktop) | [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 POST https://api.agent37.com/v1/instances/{id}/exec Run any shell command inside an instance from your backend, the escape hatch for anything the API does not wrap. Run a shell command on your instance and read its output. ```bash curl wrap theme={null} curl -X POST \ https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "command": "node --version", "user": "root" }' ``` ```python Python wrap theme={null} import requests response = requests.post( "https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec", headers={ "X-Agent37-Key": "sk_live_...", "Content-Type": "application/json" }, json={ "command": "node --version", "user": "root" }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch( "https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec", { method: "POST", headers: { "X-Agent37-Key": "sk_live_...", "Content-Type": "application/json" }, body: JSON.stringify({ "command": "node --version", "user": "root" }), }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json 200 wrap theme={null} { "exit_code": 0, "stdout": "v24.2.0\n", "stderr": "", "truncated": false } ``` `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 (or as `root`, with `user`), on the same box the agent works on, so it sees the agent's files, tools, and credentials. ## Where to call it Call exec on `https://sandbox.agent37.app`, with the same `sk_live_` key sent as the `X-Agent37-Key` header, the same way you call [chat](/docs/agents-api/chat) and [files](/docs/agents-api/files) on your instance URL. The request goes to the machine your instance runs on, so a command typically answers in a couple of hundred milliseconds instead of a couple of seconds. ```bash curl wrap theme={null} curl -X POST https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec \ -H "X-Agent37-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "command": "node --version" }' ``` The original address, `https://api.agent37.com/v1/instances/{id}/exec` with `Authorization: Bearer`, keeps working and returns the same result, so existing code needs no change. It forwards your call to the address above, which costs it a hop, so new code should use `sandbox.agent37.app`. One thing differs there: a request turned away before it reaches the instance (a bad key, an instance that is not yours, a failed wake) gets a flat [transport error](/docs/agents-api/errors#transport-errors) such as `{"error": "not_found"}` instead of the `{"error": {"code": ...}}` envelope, so check whether `error` is a string before reading `code`. If your workspace has a [custom domain](/docs/agents-api/domains), `https://sandbox.{yourdomain}` serves the same endpoint. A [signed URL](/docs/agents-api/urls) cannot authorize exec: it is API-key-only, on both addresses. ## Request **command** `string` *required* 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`. The whole request body is capped at 128 KB, and a larger one returns `413 invalid_request`: to move a bigger file into the instance, use [`scp` over SSH](/docs/agents-api/ssh). **user** `string` Who runs the command. Omit it to run as the image's default user; pass `"root"` to run as root, for example to install system packages with `apt-get`. Any other value returns `400 invalid_request`. **name** `string` A label for this run, 1 to 128 characters, that makes a resend safe. If your connection drops before the answer arrives, send the identical request again with the same `name`: you get that run's result, waiting for it if it is still running, and the command does not run a second time. A name is remembered while its command runs and for 5 minutes after it finishes; the same name with a different command in that time returns `400 invalid_request`. Leave it out and every call runs. Calls to `api.agent37.com` do this for you. > [!NOTE] > > A `running` instance runs the command right away. A `sleeping` instance is woken first, the same way a request to one of its URLs or an explicit [start](/docs/agents-api/instances/start) wakes it. The command runs after the wake completes. If the wake has to rebuild the instance, it boots fresh: files on disk are kept, processes and in-memory state are not. See [Auto-sleep](/docs/agents-api/instance-auto-sleep). The wake can fail the way a start can: `409 try_again` means a sleep checkpoint was being written (tens of seconds on a large instance, retry with backoff), `502 provisioning_failed` means the wake failed, `409 capacity_unavailable` means a rebuild found no host with room, and `402 insufficient_balance` means the instance is suspended for non-payment. Those are the codes on `api.agent37.com`; on `sandbox.agent37.app` a wake that fails answers `503 wake_failed` or `503 wake_timeout`, both safe to retry, and a suspended instance answers `402 instance_suspended`. Exec does not wait for a wake another request started: an instance that is already `waking` returns `400 invalid_request`, as does any other status that is not `running` or `sleeping` (a deleted instance returns `404 not_found`). If the platform cannot reach the instance at all, you get `502 provisioning_failed`. Every exec counts as [activity](/docs/agents-api/instance-auto-sleep), so an instance you drive only through exec stays up while commands keep coming. One instance runs up to 48 commands at a time; past that you get `503 try_again`, which is safe to retry with backoff because the refused command never started. A `503 try_again` also appears for up to a minute after an instance changes hosts, which a resize can do: retry and it resolves itself. ## 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. **exit\_code** `integer` The command's exit code. Nonzero is still a `200`; read this to branch. **stdout** `string` Standard output, capped at 512 KB. See `truncated`. **stderr** `string` Standard error, with its own separate 512 KB cap. **truncated** `boolean` `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. > [!NOTE] > > `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. On `sandbox.agent37.app` the call waits up to 15 minutes (900 seconds) for the command, then answers `504 try_again`; on `api.agent37.com` it waits 13 minutes (780 seconds), a wake from sleep included, then fails with `502 provisioning_failed`. The command itself is not stopped by either: it keeps running inside the instance, so a blind retry starts a second copy. For longer jobs, start the command in the background with its output sent to a file, `nohup ./job.sh > job.log 2>&1 &`, and poll with a second exec. The redirect is required: a background command that still writes to the call's own output holds the call open until it exits. > [!WARNING] > > A command that runs for minutes sends nothing back until it finishes, and some HTTP clients quietly give up on a connection that goes quiet, or have it closed under them. `curl` is fine as shipped. Node's `http`/`https` and `fetch` are not: enable TCP keepalive on the socket (`req.on("socket", (s) => s.setKeepAlive(true, 15000))`), and prefer `http.request` over `fetch`, whose 300-second limit on waiting for a response cannot be raised. Measured on a real 330-second command: reset at 108 seconds without keepalive, clean with it. Backgrounding the command, as below, avoids the question entirely. ## Build on exec Anything the API does not wrap as its own endpoint, you build on `exec`. For moving files, use [`scp` over SSH](/docs/agents-api/ssh), which works on every template, or on an agent template the instance's own [files endpoints](/docs/agents-api/files) (`PUT /v1/files/content` to upload, `GET /v1/files/content` to download). 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 wrap theme={null} curl -X POST https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec \ -H "X-Agent37-Key: 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 wrap theme={null} curl -X POST https://sandbox.agent37.app/v1/instances/ab12cd34ef/exec \ -H "X-Agent37-Key: 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. # Send feedback Source: https://www.agent37.com/docs/agents-api/feedback POST https://api.agent37.com/v1/feedback Tell the Agent37 team about a bug, a missing feature, or a gap in these docs, straight from your agent. Send the Agent37 team a note with the same workspace key you use everywhere else. It is built for coding agents: when the API does something the docs did not lead you to expect, a capability you need is missing, or a page is wrong or silent on something, post it here. A person on our team reads every one. Write it the way you would file an issue: what you were trying to do, what you called, what happened, and what you expected. Leave out secrets and API keys. This endpoint sends no reply; for help with a problem, email [support@agent37.com](mailto:support@agent37.com). ```bash curl wrap theme={null} curl -X POST \ https://api.agent37.com/v1/feedback \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"message": "Creating 20 instances in parallel, 3 returned 503 no_capacity. Retrying a few seconds later worked.", "kind": "bug", "instance_id": "a1b2c3d4e5"}' ``` ```python Python wrap theme={null} import requests response = requests.post( "https://api.agent37.com/v1/feedback", headers={"Authorization": "Bearer sk_live_..."}, json={ "message": "Creating 20 instances in parallel, 3 returned 503 no_capacity. Retrying a few seconds later worked.", "kind": "bug", "instance_id": "a1b2c3d4e5", }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const response = await fetch("https://api.agent37.com/v1/feedback", { method: "POST", headers: { Authorization: "Bearer sk_live_...", "Content-Type": "application/json", }, body: JSON.stringify({ message: "Creating 20 instances in parallel, 3 returned 503 no_capacity. Retrying a few seconds later worked.", kind: "bug", instance_id: "a1b2c3d4e5", }), }); if (!response.ok) throw new Error(await response.text()); console.log(await response.json()); ``` ```json 201 theme={null} { "id": "Qm3vX8kTzR1aP5yLw2Hc" } ``` **message** `string` *required* The feedback, in plain text, up to 4,000 characters. **kind** `string` (default: other) One of `bug`, `feature`, `docs`, or `other`. **instance\_id** `string` The instance the feedback is about, if any. It must belong to your workspace. **id** `string` The id we filed the feedback under. Quote it if you write to support about the same thing. ## Errors | Status | Code | Meaning | | - | - | - | | `400` | `invalid_request` | `message` is missing, empty, or over 4,000 characters, or `kind` is not one of the four values. | | `404` | `not_found` | `instance_id` names no instance in this workspace. | | `429` | `rate_limited` | The workspace has sent 20 reports today. The count resets at midnight UTC. | # 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. These endpoints are served by the agent on an agent template such as `agent37-hermes`. A template with no agent, `agent37-base` included, does not have them: move files there with [`scp` over SSH](/docs/agents-api/ssh). 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 its home directory, `/home/node`, 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. > [!NOTE] > > Every timestamp here is `modified`, the file's mtime in **epoch milliseconds**, following the Agent API convention (the Sandbox 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. **name** `string` The basename, e.g. `leads.csv`. **path** `string` 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). **type** `string` `file`, `directory`, `symlink`, or `other` (sockets, devices, FIFOs). **size** `integer | null` Size in bytes; `null` for directories. **modified** `number` Last-modified time (mtime) in epoch milliseconds. **hidden** `boolean` `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. **path** `string` The directory to list. Optional; defaults to the agent's workspace, `/home/node`. Accepts absolute and `~/` paths. A path that exists but is not a directory returns `400 not_a_directory`. ```bash curl wrap theme={null} curl -X GET \ https://ab12cd34ef.agent37.app/v1/files \ --url-query "path=~/" \ -H "X-Agent37-Key: sk_live_..." ``` ```python Python wrap theme={null} import requests response = requests.get( "https://ab12cd34ef.agent37.app/v1/files", headers={ "X-Agent37-Key": "sk_live_..." }, params={ "path": "~/" }, ) response.raise_for_status() print(response.json()) ``` ```javascript Node wrap theme={null} const query = new URLSearchParams({ "path": "~/" }); const response = await fetch( "https://ab12cd34ef.agent37.app/v1/files" + "?" + query, { method: "GET", headers: { "X-Agent37-Key": "sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } console.log(await response.json()); ``` ```json response wrap theme={null} { "path": "/home/node", "parentPath": "/home", "entries": [ { "name": ".hermes", "path": "/home/node/.hermes", "type": "directory", "size": null, "modified": 1781049500000, "hidden": true }, { "name": "reports", "path": "/home/node/reports", "type": "directory", "size": null, "modified": 1781049600000, "hidden": false }, { "name": "leads.csv", "path": "/home/node/leads.csv", "type": "file", "size": 18244, "modified": 1781049642000, "hidden": false } ], "truncated": false } ``` **path** `string` The resolved absolute path of the directory you listed. **parentPath** `string | null` The parent directory's absolute path, or `null` at the filesystem root. **entries** `array` The directory's immediate children as [`FileEntry`](#the-file-entry) objects. One level only; this never recurses. **truncated** `boolean` `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. **path** `string` *required* 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`. **disposition** `string` (default: attachment) `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 wrap theme={null} curl -X GET \ https://ab12cd34ef.agent37.app/v1/files/content \ --url-query "path=~/reports/ev-makers-memo.md" \ --url-query "disposition=attachment" \ -H "X-Agent37-Key: sk_live_..." \ -o ev-makers-memo.md ``` ```python Python wrap theme={null} import requests response = requests.get( "https://ab12cd34ef.agent37.app/v1/files/content", headers={ "X-Agent37-Key": "sk_live_..." }, params={ "path": "~/reports/ev-makers-memo.md", "disposition": "attachment" }, ) response.raise_for_status() with open("ev-makers-memo.md", "wb") as output: output.write(response.content) ``` ```javascript Node wrap theme={null} import fs from "node:fs/promises"; const query = new URLSearchParams({ "path": "~/reports/ev-makers-memo.md", "disposition": "attachment" }); const response = await fetch( "https://ab12cd34ef.agent37.app/v1/files/content" + "?" + query, { method: "GET", headers: { "X-Agent37-Key": "sk_live_..." }, }, ); if (!response.ok) { throw new Error(await response.text()); } await fs.writeFile( "ev-makers-memo.md", Buffer.from(await response.arrayBuffer()), ); ``` > [!WARNING] > > 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 (`