HTTP API
The box's HTTP API is how the portal, a phone and your own scripts list tasks, start them, answer approvals, undo work and follow the action log. Every operation is documented in full under HTTP API operations, generated from the routes the core serves.
Where it listens
Section titled “Where it listens”| Address | Who can use it |
|---|---|
http://127.0.0.1:7700 |
Processes on the box. The default. |
http://<BOX_LISTEN>:7700 |
Anything that can reach that address and holds a paired device token, once you set BOX_LISTEN. GurtOS Server from the ISO listens on every address, and its firewall admits private networks only. |
BOX_HOME/api.sock |
Root on the box. A Unix socket with mode 0600, serving the same routes. |
BOX_PORT changes the port, and BOX_PORT=0 turns the TCP listener off. The socket is always on.
Every route answers both with and without the /api prefix. These two requests are the same:
curl -s http://127.0.0.1:7700/api/taskscurl -s http://127.0.0.1:7700/tasksApp routes (/t/<app>/...) and webhooks (/hook/<app>/<name>) are the exception. They answer only at those paths.
Who may call what
Section titled “Who may call what”The box sorts every request into one of these kinds of caller.
| Caller | How the box knows | What it may do |
|---|---|---|
| The box's own screen | The request comes over the socket. Or it comes from 127.0.0.1 or ::1 with no proxy header (X-Forwarded-For, Forwarded, X-Real-IP, X-Forwarded-Host or Tailscale-User-Login), a Host of 127.0.0.1, localhost or [::1], and no Origin except the box's own address on the API port |
Everything |
| A helper on the box | The same, but the connecting process runs as nobody or as a task user |
Read, start tasks and answer approvals. Never the owner's routes below |
| A paired device | Authorization: Bearer <token>, or ?access_token=<token> for the log stream, which a browser's EventSource cannot send headers to |
Read, start tasks, undo and answer approvals |
| A device paired as the owner | A token from a code made with {"owner": true} on the box's own screen |
Everything a paired device may do, plus the owner's routes below |
| An app's window | A token from POST /api/apps/<id>/window, which starts with w |
Only that app's own routes, GET /api/tasks, GET /api/tasks/<id>, GET /api/approvals, and answers to that app's own requests. See App routes |
A web page from another site, opened in a browser on the box, also arrives from 127.0.0.1. Its Origin (or, for a page that points its own name at 127.0.0.1, its Host) gives it away, so it needs a token. So does a page that another program on the box serves, such as an agent's dev server on localhost:8000, unless its origin is named in BOX_ORIGINS. A * in BOX_ORIGINS never counts as the box's own screen.
Anyone else gets 401 and nothing is read, started, approved or undone. These stay open without a token:
GET /api/health, which then answers only{ok, startedAt, paused}.POST /api/pair, redeeming a pairing code.- The work-account sign-in:
GET /api/sso,GET /api/sso/start,GET /api/sso/callbackandPOST /api/sso/redeem. - The portal's own pages and assets, which hold nothing private.
App routes and webhooks need a token too, except routes an app lists in openRoutes. A few apps do (Mana, Jira, Apify, Receipts, and Wallet's sell routes). Each checks its own secret, and the owner can turn that off per app in Settings, Permissions.
These routes are the owner's. Any other caller gets 403:
- making pairing codes, listing devices and removing other devices
- setup (steps, finish, reopen, the doctor and stopping the setup port), settings, app permissions, the vault, and turning apps on and off
- setting the owner, and starting a task on the owner's folders
- Keep and Undo Keep, and Undo of a task that was kept
- turning remote access on or off, choosing the relay, and Quick Start (
GETand stop)
On a GurtOS Business box, a device paired for a person acts as that person, and their role limits what works. People, the policy and the kill switch are for an admin or the owner, the audit is for an auditor, an admin or the owner, and an auditor's requests other than GET are refused with 403. See Turn on Business.
A request relayed by a reverse proxy or a tailnet service on the box arrives from 127.0.0.1, but it carries a proxy header, so it counts as an outside caller and needs a token.
When a paired device answers an approval, the log names the device. The by field in the request body is used only when the box's own screen answers.
Requests and responses
Section titled “Requests and responses”Send JSON with content-type: application/json and read JSON back. A body that is not valid JSON is a 400. A body over 10 MB is a 413, and over 1 MB on the routes that need no token (pairing, the work-account sign-in, an app's open routes). The box stops reading it.
curl -s http://127.0.0.1:7700/api/tasks \ -H 'content-type: application/json' \ -d '{"title": "Summarise the notes", "prompt": "Read notes.md and write summary.md.", "budgetCents": 50}'From another machine, send the device token:
curl -s http://192.0.2.10:7700/api/tasks -H 'authorization: Bearer <device token>'Errors
Section titled “Errors”A failed request returns an error status and an object with one field:
{ "error": "no workspace notes" }| Status | When |
|---|---|
| 400 | Bad input, a task field the box does not know, a bad setting, or an id that does not exist (no task t3k9x2a1b2, no approval ...) |
| 401 | An outside request with no token, or with a token that is unknown or revoked. A window token that expired. |
| 403 | A route that is the owner's (or a Business admin's), called by anyone else. An app's window asking for anything outside its own routes. |
| 404 | No such route, or an unknown device, app or person |
| 409 | The request clashes with the box's state: Undo of a kept task after you edited a kept file, a relay change while remote access is on, a remote access name that is taken, setup that is already finished |
| 413 | A request body over the size cap |
| 429 | Too many wrong pairing codes from one address, a Watch token made more than 3 times an hour, or a test push within 10 seconds of the last |
| 500 | An app route threw an error |
| 502 | The relay could not be reached, or sent an answer the box does not accept |
Cross-origin requests
Section titled “Cross-origin requests”The portal is served from the same address as the API, so it needs no CORS. The GurtOS web app's origin (BOX_APP, https://app.gurtos.com by default) is always allowed. The hosted web app can only reach a box at an https address that a relay gives it. A web app served from another origin, such as a development server, needs that origin in BOX_ORIGINS:
BOX_ORIGINS=http://localhost:5173Separate several origins with commas, or use * for any origin. For an allowed origin the box answers OPTIONS preflight requests with 204 and adds access-control-allow-origin for GET, POST, PUT, PATCH, DELETE and OPTIONS. The origin null, which a sandboxed app page has, is allowed too. The request headers it allows are content-type, authorization and x-box-person. A token is required either way. A browser EventSource cannot send headers, so it passes the token as ?access_token=.
The portal on the same address
Section titled “The portal on the same address”The box serves the built portal from portal/dist (or the folder in BOX_PORTAL) on the API's address. A GET for a file returns the file. A page load (an Accept header with text/html) for any other path returns index.html, so the portal's own routes work. A fetch('/tasks') without text/html still reaches the API. The box's own screen is the portal at /screen. Portal files other than index.html carry access-control-allow-origin: *, so a sandboxed app page can load /kit/gurtos.js as a module.
App routes
Section titled “App routes”An app's routes are served at /t/<app>/<path>. For example, the wallet app's page is /t/wallet/ and the shopper's store is /t/shopper/store. The box tells the route who is calling in an x-box-caller header that the caller cannot set: screen, owner, device:<id>, service (a helper on the box) or none (an open route reached with no token). GET /api/apps lists the windows of the apps that are on; GET /api/apps?all=1 lists every installed app, on or off. See Apps and slots.
App pages are sandboxed
Section titled “App pages are sandboxed”Every HTML or XML answer from an app route (SVG included) carries Content-Security-Policy: sandbox with scripts, forms, popups, dialogs, downloads and links a person clicks, and never allow-same-origin. The page has no origin of its own. A bug in one app's page cannot act as the box's screen, reach into the portal around it, or read the opener's token or storage, so a page keeps its state in memory.
The portal opens a page in two steps:
POST /api/apps/<id>/windowreturns{token, expiresAt}. The box's own screen, or any paired device, may call it. The app must be on (404if not).- The page opens at
/t/<id>/?access_token=<token>.
A window token lasts 12 hours, or until the device that opened it is removed. It reaches only:
- the app's own routes,
/t/<id>and everything under it, as whoever opened the window GET /api/tasksandGET /api/tasks/<task id>GET /api/approvalsPOST /api/approvals/<id>for a request of that app's own tasks
Anything else is 403. An approval that belongs to another app's task comes back as 403 with {error, needs: "shell", task}, and the portal opens that task's window so a person answers there. The box puts a short script at the top of each HTML page that adds the token to the page's own fetch calls, and /kit/gurtos.js does the same for links, forms and EventSource. A request the browser makes by itself (a module script, an image) carries no header, so add location.search to its address. Passkeys need a real origin, so they do not work inside a window.
Webhooks
Section titled “Webhooks”An app that uses the webhook trigger serves POST /hook/<app>/<name>. The JSON body becomes the payload, and the app usually starts a task:
curl -s http://127.0.0.1:7700/hook/example/start \ -H 'content-type: application/json' -d '{"title": "From a webhook"}'The response is the new task, or {"ok": true} if the app chose not to start one. A handler that throws, or a body that is not JSON, is a 400. From another machine the caller needs a device token, like any other route. See Triggers.
Following the action log
Section titled “Following the action log”Two ways to read the log as it grows:
- Polling.
GET /api/log?after=<seq>returns every event after that line. Keep the lastseqyou saw. - Streaming.
GET /api/log/streamsends Server-Sent Events. It replays from?after=<seq>or theLast-Event-IDheader (the header wins), then streams new events live. Each event isid: <seq>anddata: <json>. A: beatcomment arrives every 25 seconds.
curl -N http://127.0.0.1:7700/api/log/stream?after=0Both accept task=<id> to follow one task. From another machine, add access_token=<token> to the stream's query. The box's own screen also follows SpacetimeDB on the box: see Live updates.
Setup, settings, apps and the vault
Section titled “Setup, settings, apps and the vault”What the first-run setup and Settings are built on. Reads of /api/setup, /api/settings, /api/system, /api/update and /api/apps are open to any paired device. Every change is the owner's.
-
GET /api/systemreturns the product, edition, edition name, version, build, hostname, time zone, network (online, interfaces), box id and name, owner,model({ready, via}),droid(where the Droid download stands),paused, start time and uptime. A Business box addscompany. -
GET /api/setupreturns{model, droid, modelKey, done, finishedAt, migrated, steps, suggested, channel}.modelKeyis false until the vault holdsANTHROPIC_API_KEYorFACTORY_API_KEY. Untildoneis true, the box's own screen shows the setup steps.POST /api/setup/step {"id": "network", "data": {...}}records a step,POST /api/setup/finishmarks setup done, andPOST /api/setup/reopenshows it again.datais at most 4 KB and is for answers, never keys.suggestedlists the usual step ids in order, and the box accepts any id. -
GET /api/setup/doctorlists what a new computer still lacks:{ok, missing: [{id, text}]}.POST /api/setup/codeis for setting up from another computer. It works only on the box's own screen, while setup is open. It returns a one-time code valid for 30 minutes and opens a setup port (7701, orBOX_SETUP_PORT), where the other computer pairs withkind: "setup". The port is plain HTTP, and the image's firewall opens it to private networks only, as it does port 7700. Set a cloud computer up from its provider's console, or over a private network such as Tailscale.POST /api/setup/stopcloses the port. Finishing setup closes it too, and a setup token ends after two hours at most. SeeAGENT-SETUP.mdin the source tree. -
GET /api/settingsreturns the settings, andPATCH /api/settingschanges only the keys you send. Each change is anoteevent in the log with the old and new value. The settings and their defaults:Setting Default Does approvalThresholdCents500A single spend above this always asks for an OK, even inside the task's budget. defaultBudgetCents100The budget of a task that names none. 0means every spend asks.watchLimitCents2500The most an Apple Watch token may approve. 0means a Watch may only deny.networkDefaultnoneWith allowlist, a task that names no hosts getsdefaultHosts.defaultHosts[]Hosts for networkDefault: allowlist, in the taskhostssyntax.agentFolders[]Personal. Folders agents may be given besides the owner's home. quietHours{enabled: false, from: "22:00", to: "07:00"}Holds pushes back, and the desktop and the phone read it for notifications. pausedfalseThe kill switch. While true no task starts and running ones hold before their next step. The box also sets it at start when permissions.jsoncannot be read (see Known limits).logRetentionDays365Stored only. The log is kept forever. updateChannel,autoUpdatestable,trueStored only. Nothing reads them yet. appearance{theme: "system", accent: ""}Stored for the screens to read. An unknown key or a bad value is a
400that names the field. -
GET /api/updatereturns the version and build, then how this computer updates:install(image,discorcheckout),slots,automatic(alwaysfalse) and, on an installed image, the bootedslot, thestate(current,ready,trial,rolledBackorunknown),default,otherandtrial. See Update and roll back. -
GET /api/appslists the apps.POST /api/apps/<id>/windowgives an app's page its token.POST /api/apps/<id>/enableand/disableturn an app on or off without a reinstall. The choice survives a restart. A new box has only the model app on. -
GET /api/permissionslists, for each app, what it asks to do and what the owner allowed.PUT /api/permissions/<app>takesdeny(a list of item ids),perDayCents(whole cents, ornullfor no daily limit) anddiskBytes(at least 1 MB, ornullfor the 5 GB default). -
GET /api/vaultlists the names of the keys in the vault and when each was set through the API.PUT /api/vault/<NAME> {"value": "..."}sets or replaces one key. An Anthropic key is checked with a free call first, and one that Anthropic rejects is a400and is not saved. The log records the name, never the value. No route returns a value. See Vault keys.
GurtOS Personal: the owner and real folders
Section titled “GurtOS Personal: the owner and real folders”GurtOS Business
Section titled “GurtOS Business”Remote access and push notifications
Section titled “Remote access and push notifications”GET /api/remotereturns{enabled, name, url, state, error?, relay}.stateisoff,starting,connectedorerror, andrelayis the relay's host. The box checks its address again at most every 30 seconds.POST /api/remote {"name": "rosa"}(owner) enrolls the name at the relay, starts the tunnel and the certificate, and returnsstate: starting. PollGET /api/remoteuntilconnected. A name is 3 to 30 lowercase letters, digits and hyphens.GET /api/remote/name/<name>(owner) asks the relay whether a name is free.DELETE /api/remote(owner) stops both and releases the name.GET /api/remote/relayreturns{url, default, custom}.PUT /api/remote/relay {"url": "https://relay.example.com"}(owner) chooses a relay. It must be anhttps://host name with no path, query or login, and it must resolve only to public addresses. It answers409while remote access is on.{"url": null}goes back to the default.GET /api/push,PUT /api/pushandDELETE /api/pushshow, set and clear the calling phone's push subscription.PUTtakes{subscription, key}from a paired device only.POST /api/push/testsends that phone a test push, or answers502with a sentence when it could not be sent, for example because the relay is not answering. Pushes go through the relay, so they need remote access on.- Quick Start sets up a new computer from one you own:
POST /api/quickstart/starton the new computer's own screen during setup,GET /api/quickstartandPOST /api/quickstart/stopfor the owner, andPOST /api/quickstart/sendandPOST /api/quickstart/send/<id>from an owner phone on the source computer.
Pairing
Section titled “Pairing”A phone pairs once and gets its own device token:
- The box's own screen asks for a one-time code,
POST /api/pair/start, and shows it as a QR code. With{"owner": true}the phone becomes an owner device. The code works once and expires after 5 minutes. - The response holds
url, which opens the GurtOS web app with the code, andboxUrl, the box's own pairing page (<address>/pair#<code>).boxUrlis there only whenBOX_URLor remote access gives the box an address. On the home network, with remote access off, the portal's QR code opensboxUrl. - The phone's pairing screen redeems the code:
POST /api/pair {"code": "...", "name": "My phone"}returns{token, device, box, edition}.kindmay beweb(the default),iphoneormac. It is never used for a security decision. - The phone sends
Authorization: Bearer <token>with every request, and the log records the phone's name when it answers an approval. DELETE /api/devices/<id>stops that token. Its next request gets 401. A paired device can also stop its own withDELETE /api/pair/self.
Wrong codes are limited to 5 a minute from one address and 60 a minute overall, then 429. The address is the one a proxy on the box put last in X-Forwarded-For.
An iphone device can ask for a separate token for its Apple Watch with POST /api/devices/self/watch. The Watch token is never the owner's. It approves only up to Settings watchLimitCents, never a Business or Shopper request, and may always deny.
The older names POST /api/pair/new, GET /api/pair/devices and POST /api/pair/devices/<id>/revoke still work. A GET /pair is a portal page, not an API route.
Every route
Section titled “Every route”The last column says who may call the route from outside the box. The box's own screen may call all of them.
| Method and path | Does | From outside |
|---|---|---|
GET /api/tasks |
List every task | Paired |
POST /api/tasks |
Start a task | Paired. Owner with folder or folders |
GET /api/tasks/<id> |
One task, its approvals and its log events | Paired |
GET /api/tasks/<id>/diff |
What the task changed, and a unified diff. ?at=<seq> shows it as of one event. |
Paired |
GET /api/tasks/<id>/replay |
The events that changed files, for stepping through a task | Paired |
POST /api/tasks/<id>/undo |
Undo the task | Paired. Owner for a kept task |
POST /api/tasks/<id>/keep |
Personal: apply a done task's changes to the real folders | Owner |
POST /api/tasks/<id>/unkeep |
Personal: undo a Keep | Owner |
GET /api/owner |
Personal: the owner, or null |
Paired |
PUT /api/owner |
Personal: make an account the owner | Owner |
DELETE /api/owner |
Personal: forget the saved owner | Owner |
GET /api/approvals?state= |
Approvals, all or in one state. ?for=me is the Business "waiting for me" list. |
Paired |
POST /api/approvals/<id> |
Approve or deny. The first answer wins. | Paired |
GET /api/log?after=&task= |
Log events after a cursor | Paired |
GET /api/log/stream?after=&task= |
The log as Server-Sent Events | Paired |
GET /api/apps |
Apps that are on, or every app with ?all=1 |
Paired |
POST /api/apps/<id>/window |
A token for an app's page | Paired |
POST /api/apps/<id>/enable |
Turn an app on | Owner |
POST /api/apps/<id>/disable |
Turn an app off | Owner |
GET /api/permissions |
What each app may do, and what the owner allowed | Owner |
PUT /api/permissions/<app> |
Change one app's permissions | Owner |
GET /api/system |
Edition, version, hostname, network, uptime | Paired |
GET /api/update |
How this computer updates, and where an update stands | Paired |
GET /api/setup |
Whether first-run setup is done | Paired |
POST /api/setup/step |
Record a setup step | Owner |
POST /api/setup/finish |
Mark setup done | Owner |
POST /api/setup/reopen |
Show setup again | Owner |
GET /api/setup/doctor |
What a new computer still lacks | Owner |
POST /api/setup/code |
A one-time code for setting up from another computer | Box's own screen only |
POST /api/setup/stop |
Close the setup port | Owner |
GET /api/settings |
The settings | Paired |
PATCH /api/settings |
Change some settings | Owner |
GET /api/vault |
Names of the keys in the vault | Owner |
PUT /api/vault/<NAME> |
Set one key | Owner |
GET /api/health |
{ok, startedAt, paused}, and more for a paired caller |
Open |
GET /api/boxes |
This box and its peers from BOX_PEERS |
Paired |
POST /api/pair/start |
Make a one-time pairing code | Owner |
POST /api/pair |
Trade a code for a device token | Open |
POST /api/pair/identity |
Register a live SpacetimeDB connection, so its answers count | Paired |
DELETE /api/pair/self |
A paired device removes itself | Paired |
POST /api/devices/self/watch |
An iPhone asks for its Apple Watch's token | Paired iPhone |
GET /api/devices |
Paired phones | Owner |
DELETE /api/devices/<id> |
Revoke a phone | Owner |
GET /api/remote |
Remote access state | Paired |
POST /api/remote |
Turn remote access on | Owner |
DELETE /api/remote |
Turn remote access off | Owner |
GET /api/remote/name/<name> |
Is a name free at the relay | Owner |
GET /api/remote/relay |
The relay in use | Paired |
PUT /api/remote/relay |
Choose a relay | Owner |
GET /api/push |
Whether this phone has notifications on | Paired |
PUT /api/push |
Turn notifications on for this phone | Paired device |
DELETE /api/push |
Turn notifications off for this phone | Paired device |
POST /api/push/test |
Send this phone a test push | Paired device |
POST /api/quickstart/start |
Quick Start on the new computer, during setup | Box's own screen only |
GET /api/quickstart |
Where Quick Start stands | Owner |
POST /api/quickstart/stop |
Stop Quick Start | Owner |
POST /api/quickstart/send |
Ask to send this computer's setup to a new one | Owner phone |
POST /api/quickstart/send/<id> |
Approve or refuse that send | Owner phone |
GET /api/business |
Business settings, policy rules, seats, pilot | Paired |
PUT /api/business |
Change them | Admin. Owner for on |
GET /api/business/route |
Who would a request go to | Paired |
GET /api/people |
The people | Admin |
POST /api/people |
Add or change a person | Admin |
DELETE /api/people/<id> |
Remove a person and their phones | Admin |
POST /api/people/<id>/pair |
A pairing code for a person | Admin |
GET /api/me |
The caller's Business view | Paired |
GET /api/audit |
Audit rows | Auditor |
GET /api/audit.csv |
The same as a CSV file | Auditor |
GET /api/pause |
Whether agents are paused | Paired |
POST /api/pause |
Pause every agent | Admin |
POST /api/resume |
Let them carry on | Admin |
GET /api/sso |
Is work-account sign-in on | Open |
GET /api/sso/start |
Send the browser to the provider | Open |
GET /api/sso/callback |
The provider's return address | Open |
POST /api/sso/redeem |
Trade a sign-in code for a device token | Open |
POST /hook/<app>/<name> |
Call an app's webhook | Paired |
/t/<app>/... |
App routes, defined by each app | Paired, or open if the app lists the route in openRoutes |
POST /api/drill stores the numbers of an incident drill (core/drill.ts) in the log. It is for the drill script and has no page.
Known limits
Section titled “Known limits”GET /api/tasks/<id>for an unknown id returns 400, not 404.- A device token stays valid until you remove the device. There is no expiry, except for a setup token (until setup finishes, two hours at most). A window token lasts 12 hours.
- Any process on the box that connects from
127.0.0.1without a proxy header, and does not run asnobodyor a task user, counts as the box's own screen. Task users cannot reach the API port, because their network goes only to the box's proxy. See Network policy. - Nothing limits how many tasks or approvals a caller can create through the API. What a task itself writes is limited: a step, question or cost is at most 64 KB (
413), a task may write 20 MB to the log in all (429), and it may hold 256 open connections to the box's proxy. See Broker. - A state file the box cannot read at start (
devices.json,owner.json,vault-meta.json,permissions.json) is set aside as<name>.torn-<time>, and the box starts with the file empty and says so in the log. An unreadabledevices.jsonmeans no phone is paired, so pair again. An unreadableowner.jsonstill counts as an owner being set. An unreadablepermissions.jsonpauses all agents, asGET /api/pauseshows, until the owner checks Settings, Permissions and resumes them. Aremote.jsonthat cannot be read is set aside asremote.json.tornand remote access starts off.