---
title: "Authorising a sandbox API call · intentic"
description: "Two credentials reach an intentic sandbox: a session for a signed-in person and a control token for a program. How to get each, and what the scopes reach."
url: "https://intentic.dev/api/auth/"
updated: "2026-10-06"
---

Start here

# Authorising a call

Two credentials reach a sandbox, and the difference between them is whether a person is present. Everything else about authorisation follows from that.

**On this page (6 sections)**

- [Two kinds of caller](#two-callers)
- [A person, in a browser](#a-person)
- [A program, anywhere](#a-program)
- [What a scope reaches](#scopes)
- [From a pipeline](#from-ci)
- [Open on purpose](#open-on-purpose)

## Two kinds of caller

A browser has a signed-in human behind it and can be sent through a sign-in flow. A script cannot. So there are two credentials, one for each, and every route on this site accepts either.

The same call, both ways

```bash
# A program, anywhere: the control token
curl "$SANDBOX/git/root/status" -H "x-intentic-control: $INTENTIC_TOKEN"

# A person, in a browser: the session minted from their sign-in
curl "$SANDBOX/git/root/status" -H "authorization: Bearer $SESSION"
```

## A person, in a browser

The sandbox authenticates the end user **directly against Google**: the browser presents a Google identity token and the daemon verifies its signature against Google's published keys. The platform never holds or signs it, which is why a compromise of the platform cannot command your sandbox. The first verified identity to arrive becomes the owner, and the owner grants everyone else a role that decides how far they reach.

`POST /system/session` exchanges that token for a session the daemon minted, and that session is what every steady-state call carries as `authorization: Bearer …`. Calling the same route again with a session that has not expired renews it, so a long-lived tab never has to sign in twice.

## A program, anywhere

Anything outside a browser presents a **control token** in the `x-intentic-control` header instead. A control token is shown once, when it is minted. The daemon keeps only its hash, so it cannot be recovered and it can be revoked at any moment. It may carry an expiry, and the daemon records who minted it and when it was last used, so the roster can say which tokens are forgotten.

The app mints one under **Sandbox → Access → API tokens**: pick the scope and how long it lives, and the token appears once with the paste-ready form for a shell, a GitHub Actions workflow or an editor. The same tab lists every token against the sandbox, whichever surface minted it, with its scope, expiry and last use, and revokes them one at a time. **Sandbox → Devices → Editor bridge** mints the `editor` slice with the editor's own settings snippet. The route behind both:

Minting one as the owner

```bash
curl -X POST "$SANDBOX/system/control/tokens" \
 -H "authorization: Bearer $OWNER_SESSION" \
 -H "content-type: application/json" \
 -d '{"label":"nightly CI","scope":"read","expiresAt":1790000000000}'

{ "id": "6f1c…", "token": "ict_9wQ…" }
```

A turn a token starts is attributed to it: the activity log's rows and the agent's card say `token:<label>` where a person's turn says their email, which is the whole reason a token has a label.

## What a scope reaches

A scope is chosen when the token is minted and stored with it, rather than worked out from the caller. That is deliberate: the daemon cannot tell an editor from a build job from a cron script, because all three are just a program holding a secret. The only honest moment to decide how far one reaches is when a person decides it.

What a rung reaches is *derived from the member tiers* rather than kept as a second list: `read` is every read a viewer may make, `drive` is everything a collaborator may do, and `land` adds the one press a collaborator's grant turns into a request. Whatever tier a route floors at, no token reaches the sandbox's own trust surface: secrets, connected accounts, the member roster, sessions, this token list, the terminal, logs, exports, money and the network.

| Scope | Reaches |
| --- | --- |
| `editor` | One conversation: run a turn, answer a card it parked on, read transcripts, search the tree. What an editor bridge holds. It cannot see the fleet and it cannot land work. |
| `read` | Everything a viewer sees: the fleet, transcripts, files, git state, CI runs, listening ports. Changes nothing. The one genuinely narrow rung, which is why it exists separately rather than as a politeness. |
| `drive` | Everything read sees, plus what a collaborator does: start, answer, steer and stop turns, rename and archive agents. Stops short of anything that moves code into the main tree. A stolen token at this rung is the agent's reach. |
| `land` | Everything drive does, plus merging a conversation's worktree into the main tree, and discarding or purging one. Separate because the usual arrangement is a program that works and a person who decides. |

Worth saying plainly: at `drive` and above, a stolen token is the agent's reach, because driving an agent means editing files and running commands in this sandbox. `read` exists separately for exactly that reason rather than as a politeness. A token presented on a route outside its scope gets a [403 that says so](https://intentic.dev/api/errors/), never a confusing 401.

## From a pipeline

A CI job holding a `drive` token can start a turn, wait for it to settle and read how it ended, without learning the API: the Marketplace action `intentic/gate-action` and `npx @intentic/gate run` do the three calls (`POST /agent`, `GET /agents/{id}` until the card settles, `POST /agents/{id}/land`when asked). The turn runs in its own conversation and branch, one per workflow run; a `land` token may also merge it. The step ends `completed`, `parked` (the agent asked a person for something), `failed`, or `timeout` (the deadline decided, and the agent keeps working).

The token goes in the repository's secret store once

```yaml
- uses: intentic/gate-action@v1
 with:
 url: https://sandbox-….intentic.dev
 token: ${{ secrets.INTENTIC_TOKEN }}
 prompt: "Review this change: ${{ github.event.pull_request.html_url }}"

# Any other CI, the same exchange:
INTENTIC_URL=… INTENTIC_TOKEN=… npx @intentic/gate run "review the change at $URL"
```

## Open on purpose

A handful of routes check no credential, because the caller provably cannot present one: `/health`, the web-chat widget's routes, an automation's webhook, a forge's pipeline webhook, a workflow gate, and the enrolment routes a new computer redeems a one-time pairing token on. Each carries its own narrower check instead of the general one. A webhook or a gate takes its own token as `?token=`, the one shape a webhook sender can carry, or as `authorization: Bearer …` from a caller that can set a header; the sandbox keeps those tokens out of its versioned configuration, shows them to maintainers only, and rotates them on demand.

Only `/health` is missing from this reference, and that is on purpose too: it is not part of the contract. It exists so a script can tell a live sandbox from a dead port, and it deliberately checks nothing.

More in Start here

[Previous ← Overview](https://intentic.dev/api/)[Next The shape of a call →](https://intentic.dev/api/calls/)
