appstrate is the official command-line tool for installing, configuring, and authenticating against an Appstrate instance. It is a single self-contained binary (Bun runtime embedded) — no Node.js, npm, or pre-installed dependencies required on the host.
Lives at apps/cli/ in the monorepo; versioned in lockstep with the platform.
Driving this CLI from an AI coding agent? Read
AGENTS.mdfirst — it distills this reference into a zero-to-first-run recipe, rules of engagement, and acurl→appstrate apicheat sheet sized for an agent's context window.
curl -fsSL https://get.appstrate.dev | bashDetects your OS/arch, downloads the matching binary from GitHub Releases, drops it at /usr/local/bin/appstrate, and immediately execs appstrate install.
Supported: darwin-arm64, darwin-x64, linux-x64, linux-arm64. Windows is not a v1 target — run the one-liner inside WSL2 (which reuses the linux-x64 binary), or invoke bunx appstrate install natively if you already have Bun on Windows.
# Verified one-liner — fetches + minisign-verifies + runs
curl -fsSL https://get.appstrate.dev/verify.sh | bash
# Bun-native (if you already have Bun)
bunx appstrate installSee examples/self-hosting/README.md for signature verification details (minisign + SLSA build provenance).
| Command | Purpose |
|---|---|
appstrate install |
Install Appstrate locally (Tier 0) or bring up a Docker stack (Tiers 1/2/3). |
appstrate start |
Start the installed Docker stack (docker compose up -d). |
appstrate stop |
Stop the stack — containers off, volumes preserved. |
appstrate restart |
Restart all containers. |
appstrate logs |
Stream Compose logs (with -f and an optional service-name positional). |
appstrate status |
Show container status (docker compose ps). |
appstrate uninstall |
Tear down. Default keeps volumes; --purge wipes data + the install dir. |
appstrate login |
Sign into an instance via RFC 8628 device-flow. Tokens land in the OS keyring. |
appstrate logout |
Revoke the active session server-side and wipe local credentials. |
appstrate whoami |
Print the identity attached to the active profile. |
appstrate token |
Print metadata about the stored access + refresh tokens (debug). |
appstrate org |
List, switch, or create organizations pinned on the active profile. |
appstrate app |
List, switch, or create applications pinned on the active profile. |
appstrate api |
Authenticated HTTP passthrough to the Appstrate API. |
appstrate openapi |
Explore the active profile's OpenAPI schema without flooding stdout. |
appstrate run |
Execute an agent locally — by package id or from a .afps/.afps-bundle path. |
All commands accept --profile <name> to target a specific profile (see Profiles).
Interactive installer for a local Appstrate instance. Prompts for a tier, writes a generated .env with cryptographic secrets, and brings the stack up — then opens http://localhost:3000 once the healthcheck passes.
appstrate install # interactive
appstrate install --tier 3 # skip the tier prompt
appstrate install --tier 0 --dir ~/demo-appstrateFlags
| Flag | Values | Description |
|---|---|---|
-t, --tier |
0|1|2|3 |
Skip the interactive tier prompt. |
-d, --dir |
path | Install directory (default: ~/appstrate). |
Tiers
| Tier | Runtime deps | Services | Storage | Notes |
|---|---|---|---|---|
| 0 | Bun | None (PGlite in-process) | Filesystem | Hobby / evaluation. CLI auto-installs Bun if missing. |
| 1 | Docker | PostgreSQL | Filesystem | Low-traffic single-node. In-memory scheduler / pubsub. |
| 2 | Docker | PostgreSQL + Redis | Filesystem | Adds Redis (BullMQ, distributed rate-limiter). |
| 3 | Docker | PostgreSQL + Redis + MinIO | S3 | Full production stack (default self-host target). |
Tier 0 specifics: git clones the appstrate/appstrate monorepo at the CLI's release tag, runs bun install, writes .env, and bun run dev spawns the platform as a detached process. If Bun is absent, the CLI prompts to install it via the official installer into ~/.bun/bin (user-local, no sudo).
Tier 1/2/3 specifics: checks docker info, writes docker-compose.yml from an embedded template (examples/self-hosting/docker-compose.tier{1,2,3}.yml), writes .env, runs docker compose up -d, polls / for up to 120s.
After appstrate install (Tiers 1/2/3) every Compose verb has a thin
wrapper that reads the recorded project name from
<dir>/.appstrate/project.json — you never type the derived hash.
appstrate start # docker compose up -d (idempotent)
appstrate stop # docker compose stop (volumes intact)
appstrate restart # docker compose restart
appstrate logs -f # docker compose logs -f
appstrate logs -f postgres # filter to a single service
appstrate status # docker compose ps
appstrate uninstall # docker compose down (data preserved)
appstrate uninstall --purge --yes # destructive: down -v + rm -rf <dir>Flags (all subcommands)
| Flag | Description |
|---|---|
-d, --dir |
Override the install directory (default ~/appstrate). |
appstrate logs
| Flag | Description |
|---|---|
-f, --follow |
Stream new lines as they arrive. |
[service] |
Optional positional — filter to a single service. |
appstrate uninstall
| Flag | Description |
|---|---|
--purge |
Also remove named volumes (Postgres, Redis, MinIO data) and the install directory. Destructive — prompts unless --yes. |
-y, --yes |
Skip the destructive-action confirmation. Equivalent to APPSTRATE_YES=1. Required for --purge in non-interactive contexts. |
The raw docker compose --project-name <hash> <verb> form remains
supported — useful when you need a flag the wrapper doesn't expose.
The project name is in ~/appstrate/.appstrate/project.json.
Authenticate against a running Appstrate instance via RFC 8628 device-authorization grant. The CLI displays a short user-code + URL; the user visits the URL in a browser, signs in, and approves the device. The CLI polls the token endpoint until approved, stores the resulting session token in the OS keyring, and persists the profile in ~/.config/appstrate/config.toml.
appstrate login # interactive prompt for instance URL
appstrate login --instance http://localhost:3000 # skip prompt
appstrate login --profile prod --instance https://app.my.ioFlags
| Flag | Values | Description |
|---|---|---|
--instance |
URL | Instance base URL. Skips the interactive prompt. |
-p, --profile |
name | Profile name to store credentials under (default: default). |
--org |
<id-or-slug> |
After the token exchange, pin this organization on the profile non-interactively. Fails if the reference does not match any org. |
--create-org |
<name> |
Create a new organization with this name and pin it. A default application + hello-world agent are provisioned server-side. Skips the prompt. |
--no-org |
— | Skip the post-login org-pinning step entirely. Subsequent calls must carry -H 'X-Org-Id: …', or pin later via appstrate org switch. |
--app |
<id> |
After the org pin, pin this application on the profile non-interactively. Fails if the reference does not match any app. |
--create-app |
<name> |
Create a new application with this name after login and pin it. Skips the cascade's default-app pick. |
--no-app |
— | Skip the post-login app-pinning step entirely. Subsequent calls must carry -H 'X-Application-Id: …', or pin later via appstrate app switch. |
Org pinning after login (issue #209): on success, the CLI calls GET /api/orgs and branches:
- Exactly one org → auto-pin. The success banner names it:
Logged in as … to "Acme" (org_xxx). - Zero orgs (fresh signup, dashboard onboarding skipped) → offer inline creation (
POST /api/orgs) which also provisions a default application + hello-world agent server-side. - ≥2 orgs → interactive picker.
The pinned org id is written to config.toml and automatically sent as X-Org-Id on every subsequent appstrate api call, so appstrate api GET /api/me works immediately after a fresh login with no extra flags.
Application pinning after login (issue #217): after the org pin succeeds, the CLI cascades into GET /api/applications and branches:
- Exactly one app → auto-pin.
- ≥2 apps → auto-pin the one with
isDefault: true(the server provisions exactly one default per org). No interactive picker at login — useappstrate app switchafterwards for a different app. - No default among ≥2 apps (defensive — should never happen) → warn on stderr, leave unpinned.
On success, the banner names both: Logged in as … to "Acme" (org_xxx) / app "Default" (app_xxx). The pinned app id is sent as X-Application-Id on every appstrate api call, so app-scoped routes (/api/agents, /api/runs, /api/schedules, …) work with no extra flags.
Flow (what happens on the wire):
POST /api/auth/device/code→ receivedevice_code,user_code,verification_uri_complete,expires_in(10 min),interval(5s).- CLI prints the code, opens the verification URI in the browser via the
openpackage (silent fallback on headless hosts — the URL is still displayed in the terminal). - User authenticates on the instance's
/activateSSR page and clicks "Autoriser". A realm guard on/device/approverejects cross-audience approval attempts (e.g. an application-level end-user trying to approve a CLI session). - CLI polls
POST /api/auth/cli/tokeneveryintervalseconds (honoringslow_downbackoff) until approval. On success: receives anaccess_token(15-minute signed JWT, ES256) +refresh_token(30-day opaque rotating token) pair — see issue #165. - CLI decodes the JWT payload locally to extract
sub(user id) andemailfrom its claims. No second round-trip needed — the JWT is the authoritative identity source, and/api/auth/get-sessiondoes not understand Bearer JWTs (that endpoint is BA's cookie-based session reader). - Tokens are stored in the OS keyring; profile is written to
config.toml.
Session lifetime: 15-minute access token + 30-day rotating refresh token (RFC 6819 §5.2.2.3 reuse detection). The CLI transparently refreshes on 401; re-run appstrate login only when the refresh token family is revoked or the 30-day window elapses.
Revokes the active session server-side (POST /api/auth/sign-out) and wipes the local keyring entry + profile from config.toml.
appstrate logout
appstrate logout --profile prod
appstrate logout --all # nukes every CLI session (every device)Flags
| Flag | Values | Description |
|---|---|---|
-p, --profile |
name | Profile to log out from (default: default). |
--all |
— | Revokes every CLI refresh-token family server-side via POST /api/auth/cli/sessions/revoke-all. Use after suspecting key compromise. |
If the instance is unreachable, local credentials are still wiped (with a warning on stderr) so the CLI returns to a clean state even during outages.
The dashboard's Devices preferences page (and GET /api/auth/cli/sessions) lets you revoke individual sessions. Org admins can revoke any member's CLI sessions via GET/DELETE /api/orgs/:orgId/cli-sessions[/:familyId] (requires the cli-sessions:read|delete RBAC grant — owners + admins by default).
Channel-aware in-place upgrade. The binary stamps its install source at build time (__APPSTRATE_INSTALL_SOURCE__), so self-update knows whether it was installed via curl, Bun, or bunx and dispatches accordingly.
appstrate self-update # update to latest stable
appstrate self-update --release v1.2.3
appstrate self-update --force # bypass version-equality short-circuit| Flag | Values | Description |
|---|---|---|
--release <tag> |
git tag | Pin the upgrade to a specific release. |
-f, --force |
— | Re-install even if already on target version. |
- curl channel — downloads the new binary, verifies minisign + SHA-256, and atomically replaces
~/.local/bin/appstrate. - bun channel — refuses to overwrite, prints the matching
bun update -g @appstrate/cliinvocation. - unknown channel — emits diagnostic instructions.
Channel matrix and recipes: docs/cli/upgrades.md.
Diagnoses the local install: detects every appstrate on $PATH, deduplicates by realpath, prints version + channel + binary location for each. Use when which -a appstrate returns multiple results or when self-update reports an unexpected channel.
appstrate doctor # human-readable report
appstrate doctor --json # machine-readable (for scripts / CI)If a dual install is detected (e.g. curl-installed binary shadowed by a Bun-global one), the runtime warns once per realpath set; ack persisted at ~/.config/appstrate/dual-install-ack.json re-arms whenever the set changes. Override with APPSTRATE_FORCE_DUAL=1 to silence non-interactively.
A hidden __install-source subcommand exposes a stable JSON contract { version, source, schema: 1 } for installers that need to gate on channel.
Prints the identity attached to a profile. Verifies the stored JWT is still valid by calling GET /api/profile (a 401 surfaces as a clear "re-login" error); the email comes from the profile persisted at login.
appstrate whoami
appstrate whoami --profile prodOutput:
Profile: default
Instance: https://app.example.com
User: alice@example.com
Name: Alice
Expires: 2026-04-25T00:36:40.285Z
Exits non-zero if the profile is missing, the session is revoked, or the instance is unreachable — useful in CI scripts that need to fail fast when auth drifts.
Prints metadata about the access + refresh tokens stored for a profile. Metadata only — the token plaintext is never written to stdout or stderr, so copy-pasting the output into a screen share, a CI log, or a bug report never leaks a bearer.
appstrate token
appstrate token --profile prodOutput:
Profile: default
Instance: https://app.example.com
Access token
Status: fresh
Expires: in 14m 32s
Expires at: 2026-04-19T16:23:45.000Z
Refresh token
Status: valid
Expires: in 29d 23h
Expires at: 2026-05-18T16:08:45.000Z
JWT claims
iss: https://app.example.com/api/auth
aud: https://app.example.com/api/auth
sub: usr_abc123
azp: appstrate-cli
actor_type: user
scope: cli
iat: 1713543325 (2026-04-19T16:08:45.000Z)
exp: 1713544225 (2026-04-19T16:23:45.000Z)
jti: ab12cd34…
Status vocabulary:
- Access:
fresh(> 30s remaining) ·rotating-soon(< 30s —api.tswill rotate on the next call) ·expired(past TTL; claims still render for diagnostics) - Refresh:
valid(> 24h remaining) ·expiring-soon(< 24h) ·expired(re-runappstrate login) ·not stored(legacy 1.x credentials)
No network call — this command inspects local state only. A refresh token revoked server-side still looks valid here by design. Use whoami for a server-authoritative identity check.
If the JWT exp claim and the locally stored expiresAt diverge by more than 2 seconds, token flags the mismatch — api.ts's proactive-rotation logic keys off the stored value, so a skew between the two is worth surfacing before it causes unexpected 401s.
Manage the organization pinned on the active profile. login auto-pins an org where possible (see above); org switch / org create let you change the pin without re-running the device flow. The pinned org id is sent as X-Org-Id on every appstrate api call and every /api/* endpoint that requires org context.
appstrate org list # enumerate orgs the profile has access to; pinned row is marked *
appstrate org switch # interactive picker (current org pre-highlighted)
appstrate org switch acme # non-interactive — by slug or id
appstrate org current # print the pinned orgId (scripts / shell prompts)
appstrate org create # interactive (name + optional slug) → auto-pin
appstrate org create "Acme" # non-interactive → auto-pin
appstrate org create "Acme" --slug acme-prodAll four subcommands respect the global --profile <name> flag and talk to GET /api/orgs / POST /api/orgs. Creating an org server-side also provisions a default application + a hello-world agent, so the CLI lands on a fully-working setup with no extra steps.
Subcommands
| Subcommand | Purpose |
|---|---|
org list |
List the orgs the active profile belongs to. The pinned one is marked with *. |
org switch [id|slug] |
Re-pin the active org on the profile. With no argument, show an interactive picker with the current one highlighted. |
org current |
Print the pinned org id to stdout. Exits 1 with a hint when no org is pinned — designed for if / shell prompts. |
org create [name] |
Create a new org and pin it. With no argument, prompt for name + optional slug. Use --slug <slug> for an explicit kebab-case override. |
Cascade — the app pin follows the org pin. Every org switch / org create clears the current applicationId and re-pins the new org's default application in the same atomic operation. This keeps appstrate api GET /api/agents working immediately after switching — without the cascade the next call would return 404 Application not found in this organization.
Manage the application pinned on the active profile. login auto-pins the default application in the pinned org (see above); app switch / app create let you change the pin without re-running the device flow. The pinned app id is sent as X-Application-Id on every appstrate api call — required for app-scoped routes (agents, runs, schedules, webhooks, api-keys, notifications, packages, integrations, end-users).
appstrate app list # enumerate apps in the pinned org; pinned row is marked *, default row tagged [default]
appstrate app switch # interactive picker (current app pre-highlighted)
appstrate app switch app_xxx # non-interactive — by id
appstrate app current # print the pinned applicationId (scripts / shell prompts)
appstrate app create # interactive (name) → auto-pin
appstrate app create "Staging" # non-interactive → auto-pinAll four subcommands respect the global --profile <name> flag and talk to GET /api/applications / POST /api/applications. Applications are identified by id only (there is no slug column server-side).
Subcommands
| Subcommand | Purpose |
|---|---|
app list |
List the applications in the pinned org. The pinned one is marked with *; the org's default is tagged [default]. |
app switch [id] |
Re-pin the active app on the profile. With no argument, show an interactive picker with the current one highlighted. |
app current |
Print the pinned app id to stdout. Exits 1 with a hint when no app is pinned — designed for if / shell prompts. |
app create [name] |
Create a new app and pin it. With no argument, prompt for a name interactively. |
Explore the active profile's OpenAPI 3.1 schema without dumping the whole spec to stdout. The platform exposes ~258 endpoints — list, show, and export subcommands make that corpus explorable at human scale (and agent-ingestable with --json).
The schema is fetched once per profile and cached under ~/.cache/appstrate/openapi-<profile>.json (or $XDG_CACHE_HOME/appstrate/…). Each cached copy pairs with an ETag sibling — subsequent invocations send If-None-Match and short-circuit on a 304 response, so re-running list / show during exploration costs one conditional round-trip instead of re-downloading the full spec.
appstrate openapi list # all operations, one per line
appstrate openapi list --tag runs # filter by tag
appstrate openapi list --method post # filter by HTTP method
appstrate openapi list --path '/api/runs/*' # filter by path glob
appstrate openapi list --search "create run" # fuzzy match on id / summary / path
appstrate openapi list --json # machine-readable index
appstrate openapi show createRun # by operationId
appstrate openapi show GET /api/runs # by METHOD + path
appstrate openapi show createRun --json # full dereferenced object (agent input)
appstrate openapi export # dump raw schema to stdout
appstrate openapi export -o schema.json # dump to fileSubcommand flags
| Subcommand | Flag | Description |
|---|---|---|
list |
--tag <t> |
Filter by OpenAPI tag (case-insensitive exact match). |
list |
--method <m> |
Filter by HTTP method (GET, POST, …). |
list |
--path <glob> |
Filter by path. Supports * (single segment) and ** (any). Exact match else. |
list |
--search <q> |
Case-insensitive substring across operationId, summary, description, path. |
list |
--json |
Emit a minimal JSON array (method, path, operationId, summary, tags) for piping. |
show |
--json |
Emit the full dereferenced operation as JSON instead of the text summary. |
export |
-o, --output |
Write the schema to a file (default: stdout). |
Shared flags (all three subcommands)
| Flag | Description |
|---|---|
--refresh |
Force a fresh download; still update the on-disk cache on success. |
--no-cache |
Fully ephemeral — skip both cache read and write for this invocation. |
list output — one colored line per operation:
GET /api/runs — List runs [runs]
POST /api/runs — Create a run [runs]
GET /api/runs/{id} — Get a run [runs]
DELETE /api/runs/{id} — Cancel a run [runs]
GET /api/deprecated — Legacy endpoint [legacy] [deprecated]
Colors are suppressed when stdout is not a TTY, or when NO_COLOR is set in the environment (respects no-color.org).
show output — a human-readable operation summary. For --json, the response uses @apidevtools/swagger-parser to dereference every $ref in the operation tree, so nested request/response schemas inline fully — ideal for piping into an LLM prompt or a code generator.
export output — the raw schema JSON. Use -o schema.json for file output (mode 0600) or stdout for shell piping (appstrate openapi export | jq '.info'). Equivalent to calling appstrate api GET /api/openapi.json, but served from the local cache when possible.
Curl-like authenticated HTTP passthrough. Purpose-built so coding agents (Claude Code, Cursor, Aider, …) can call the Appstrate API in a shell-one-liner without ever seeing the raw bearer — the CLI injects Authorization: Bearer … + X-Org-Id + X-Application-Id from the keyring-backed profile.
appstrate api GET /api/agents
appstrate api /api/agents # method inferred
appstrate api POST /api/agents/abc/run -d '@req.json'
appstrate api https://app.example.com/api/health # absolute URL ok if origin matches profileEvery row below is a direct drop-in: an agent can replace curl with appstrate api and strip the hostname. All flags work identically.
| curl | appstrate api |
Notes |
|---|---|---|
curl https://app/api/x |
appstrate api /api/x |
method defaults to GET |
curl -X POST -d @body … |
appstrate api POST /api/x -d @body |
literal / @file / @- for stdin |
curl -F 'file=@pkg.zip' |
appstrate api -F 'file=@pkg.zip' |
;type=mime supported |
curl -H 'X-Foo: bar' |
appstrate api -H 'X-Foo: bar' |
repeatable; wins over defaults |
curl --data-urlencode 'k=v w' |
same | repeatable; 5 curl forms incl. @file / @- |
curl -G --data-urlencode … |
appstrate api -G --data-urlencode … |
-G projects values into the query string |
curl -T file |
appstrate api -T file /x |
PUT by default; -T - for stdin |
curl -i |
appstrate api -i |
status line + headers on stdout |
curl -I |
appstrate api -I |
HEAD only |
curl -L |
appstrate api -L |
cross-origin hops strip Authorization |
curl -k |
appstrate api -k |
skip TLS verification (this request) |
curl -o out |
appstrate api -o out |
body → file |
curl -s / -sS |
appstrate api -s / -sS |
silence / silence-but-errors |
curl -f / --fail-with-body |
same | -f suppresses body; --fail-with-body keeps it |
curl -v |
appstrate api -v |
Authorization always [REDACTED] |
curl -w '%{http_code}\n' |
appstrate api -w '%{http_code}\n' |
see write-out vars below |
curl --connect-timeout N |
appstrate api --connect-timeout N |
exit 28 on timeout |
curl --max-time N |
appstrate api --max-time N |
exit 28 |
curl --retry N |
appstrate api --retry N |
408/429/5xx; exp. backoff; Retry-After honored |
curl --retry-connrefused |
same | off by default (matches curl) |
curl --compressed |
appstrate api --compressed |
advertise gzip/deflate/br |
curl -r 0-1023 |
appstrate api -r 0-1023 |
Range: bytes=… |
curl -A 'UA' |
appstrate api -A 'UA' |
shortcut; -H still wins |
curl -e https://ref |
appstrate api -e https://ref |
Referer shortcut |
curl -b 'k=v' |
appstrate api -b 'k=v' |
literal only; cookie-jar files rejected |
Subset of curl's format string. Unknown variables pass through verbatim; \n \r \t escapes are expanded.
| Variable | Meaning |
|---|---|
%{http_code} |
Final response status (0 on connect failure) |
%{http_version} |
Hardcoded 1.1 — fetch() doesn't expose the real version |
%{size_download} |
Body bytes received |
%{size_upload} |
Body bytes sent (0 when unknown — FormData / stream) |
%{time_total} |
Total time in seconds, 6 decimals |
%{time_starttransfer} |
Time until first response byte |
%{url_effective} |
Final URL after redirects |
%{num_redirects} |
1 if -L followed a redirect, else 0 |
%{header_json} |
Response headers as JSON |
%{exitcode} |
Our process exit code |
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic / auth error |
| 2 | Usage error (foreign host, -G + -F, cookie-jar path) |
| 6 | DNS failure (ENOTFOUND / EAI_AGAIN) |
| 7 | Connection refused / unreachable |
| 22 | HTTP ≥ 400 under -f / --fail-with-body |
| 25 | HTTP ≥ 500 under -f / --fail-with-body |
| 28 | --max-time or --connect-timeout expired |
| 35 | TLS handshake failure |
| 130 | SIGINT |
- No
-u / --user: the whole point is that agents never see the bearer. Use-H Authorization: …if you really need to override (it's still[REDACTED]under-v). - Cross-origin
<url>refused: the bearer must not leave the profile's instance. Explicit exit 2 with a pointer at plaincurl. - Cookie jars rejected:
-b file.txtis refused (exit 2). An attacker-controlled path would otherwise silently end up in the Cookie header. - No default
Content-Type:-d/--data-urlencodedon't auto-setapplication/x-www-form-urlencodedthe way curl does. Add-H 'Content-Type: …'explicitly when the server expects it (avoids corrupting multipart / binary payloads elsewhere in the API).
%{http_version}always reports1.1: Web fetch doesn't expose the negotiated protocol. All other-wvariables are accurate.%{header_json}emits lowercase header names: WHATWG fetch normalizes response header casing; curl preserves the wire casing. Parsers that key on lowercase are unaffected; case-sensitive parsers need adjustment.--connect-timeoutis wall-clock, not per-attempt under--retry: the timer starts once at the first fetch and aborts the whole run if response headers haven't arrived. curl resets it per attempt. In practice this only differs when the first attempt partially succeeds then fails mid-body (rare); retries on DNS / network errors that never touch the socket are unaffected.--retrydisabled automatically on stdin bodies:-d @-,-T -,--data-urlencode @-can't be replayed after the stream is consumed. The CLI warns on stderr and falls back to a single attempt instead of silently replaying an empty body.Retry-Afterdelta-seconds values capped at 1 hour: server-suggested delays above 3600 seconds are ignored and fall back to exponential backoff. A hostile / misconfigured origin can't stall a CI job overnight.
Execute an agent locally via the same Pi runner the platform uses for cloud runs. Two argument forms:
- By package id —
@scope/agent[@spec]. The CLI callsGET /api/agents/{scope}/{name}/bundleon the pinned instance to download a deterministic.afps-bundle, verifies its SRI integrity in memory, and runs it. The bytes are never written to disk — every invocation re-fetches. - By file path — a local
.afpsor.afps-bundlefile. No network roundtrip.
# Run the latest version of an installed agent
appstrate run @scope/triage
# Pin an exact version
appstrate run @scope/triage@1.2.0
# Resolve via dist-tag
appstrate run @scope/triage@beta
# Run a local bundle without hitting the instance
appstrate run ./out/triage-1.2.0.afps-bundle --integrations local --creds-file ./creds.jsonRun-config inheritance (model, proxy, agent config, version pin) is fetched from /api/applications/{applicationId}/packages/{scope}/{name}/run-config and merged with flag/env overrides. Use --no-inherit to opt out (deterministic CI).
Selected flags
| Flag | Purpose |
|---|---|
--proxy <id> |
Proxy id to associate with the run (overrides the per-app inherited value). |
--no-inherit |
Skip per-application run-config inheritance — flags + env vars + defaults only. |
--json |
Emit canonical RunEvents as JSONL on stdout. |
-v, --verbose |
Verbose tool-call output: pretty-print args + reveal full results (~2 KB). Honoured only in human mode (without --json). Env: APPSTRATE_VERBOSE=1. |
-q, --quiet |
Suppress per-tool output lines (name, args, result). Errors and final summary still print. Mutually exclusive with --verbose. |
Tool-call rendering
In human mode (no --json), each tool call surfaces as one to three lines:
→ tool: bash
args command: "ls -la /tmp", timeout: 5000
✓ result total 8 ↵ drwxr-xr-x 3 root ...
Defaults match the dashboard log viewer: args truncated at 200 chars, result preview at 100 chars (newlines collapsed to ↵). Pass -v to pretty-print args as multi-line JSON and reveal the full ~2 KB result; pass -q to suppress tool lines entirely (errors + summary always print). The bridge truncates oversized results to ~2 KB before transport — a __truncated: true marker stays visible in either mode so silent data loss is impossible.
The full flag set is documented under appstrate run --help.
Connection readiness
Connection readiness is enforced server-side at run-trigger time: a run that targets an integration without a healthy connection is rejected with HTTP 412 (missing_integration_connection) before the container launches. Connect or repair the connection from the dashboard's connectors panel (${instance}/preferences/connectors).
Multiple Appstrate instances (dev / prod / a customer deploy / ...) can be kept side by side via named profiles. Resolution cascade (first match wins):
--profile <name>flagAPPSTRATE_PROFILEenvironment variabledefaultProfilekey inconfig.toml(set on the first successful login)- Literal
"default"
Each profile stores the instance URL + user identity in ~/.config/appstrate/config.toml (TOML, 0600 perms); the session token lives in the OS keyring entry (appstrate, <profile-name>).
# Sign into prod, pinned profile name
appstrate login --profile prod --instance https://app.example.com
# Make prod the default for future invocations
APPSTRATE_PROFILE=prod appstrate whoami
# → or edit defaultProfile in ~/.config/appstrate/config.tomlTokens are stored in the OS keyring when available, otherwise in a file fallback.
| Platform | Primary backend | Fallback |
|---|---|---|
| macOS | Keychain (via @napi-rs/keyring) |
~/.config/appstrate/credentials.json (0600) |
| Linux | libsecret / DBus (via @napi-rs/keyring) |
idem (triggers on stripped containers without DBus) |
| Windows | Credential Manager (via @napi-rs/keyring) |
idem |
The fallback activates transparently when the keyring backend is missing (common in headless CI containers). A one-time stderr warning fires if the keyring backend reports a non-missing-backend error (corrupt DB, locked Keychain) — that way a legitimate misconfiguration doesn't silently degrade to plaintext storage.
$XDG_CONFIG_HOME/appstrate/ (or ~/.config/appstrate/)
├── config.toml # profiles, default profile pointer
└── credentials.json # keyring fallback (only if keyring unavailable)
Example config.toml:
defaultProfile = "prod"
[profile.prod]
instance = "https://app.example.com"
userId = "EWnC2cLyy88EpCGBa3WrIdS7uqI648BB"
email = "alice@example.com"
orgId = "org_123abc"
applicationId = "app_abc123"
[profile.dev]
instance = "http://localhost:3000"
userId = "SVAA9PSXrmqQmg95A3RzyydtlravhhJR"
email = "dev@example.com"orgId and applicationId are both optional — when set, every apiFetch request sends X-Org-Id: <orgId> (and X-Application-Id: <applicationId>) so the instance scopes requests correctly. Unset orgId means the user's default org applies server-side; unset applicationId means app-scoped routes (/api/agents, /api/runs, …) return 400 Application context required and the caller must pass -H X-Application-Id: … manually.
Unauthorized — your session may have been revoked
Session expired or was revoked server-side. Re-run appstrate login.
Application context required. Provide X-Application-Id header or use an API key.
The pinned profile has no applicationId. Either the cascade at login skipped it (--no-app, zero apps in the org, or no default found), or a previous org switch cleared it and the re-pin fetch flaked. Run appstrate app switch to pick one, or pass -H 'X-Application-Id: …' on the call.
Application '<id>' not found in this organization
The pinned applicationId belongs to a different org than the currently pinned orgId. Happens when something mutates config.toml between an org switch and the next API call, or after a manual hand-edit. Run appstrate app switch to re-pin a valid app under the current org.
This CLI is not registered on the target instance. The platform may be running an incompatible version.
The instance's appstrate-cli OAuth client is missing. Boot the platform — ensureCliClient() auto-provisions it on startup. If the instance is much older than the CLI (pre-Phase-1 device flow), the CLI binary is incompatible — downgrade via APPSTRATE_VERSION=<older-tag> curl get.appstrate.dev | bash.
Docker is required for this tier but was not found
appstrate install --tier {1,2,3} needs Docker. Install Docker Desktop (macOS) or the Docker engine (Linux) and re-run. On Windows, run inside WSL2 with the Docker engine installed in the WSL distro (Docker Desktop's WSL integration also works). Tier 0 doesn't need Docker.
Bun is not installed.
Tier 0 bootstrap couldn't find bun on PATH. The CLI offers to install it via the upstream curl https://bun.sh/install | bash (user-local, no sudo). Decline to install manually from bun.sh.
Keyring fallback warning
If you see OS keyring ... failed ... falling back to ~/.config/appstrate/credentials.json, the OS keyring is broken (libsecret unreachable on Linux, Keychain locked on macOS). The file fallback is 0600 but is plaintext — fix the keyring backend if you want secure-at-rest storage.
Source at apps/cli/. Tests at apps/cli/test/ (unit tests, run with bun test from the CLI directory). E2E against a real instance: spin up an Appstrate Tier 0 with bun run dev, then bun run src/cli.ts login --instance http://localhost:3000.
bun build --compile --target=bun-<host> produces a working standalone binary for the host platform — @napi-rs/keyring's native .node binding is resolved from node_modules at bundle time and embedded into the output.
Cross-compiling from a single host does not work. bun build --compile --target=bun-linux-x64 from a macOS machine (or any other mismatched combination) will compile successfully but replace every require("./keyring.<target>.node") with a throw new Error("Cannot require module …"), because only the host-matching @napi-rs/keyring-<platform> optional dependency is installed by bun install. The binary will start, print --help, and crash the moment any code path touches the keyring (login, logout, whoami).
The release pipeline (.github/workflows/release.yml) handles this by running one job per target on a native runner (macOS arm64, macOS x64, Linux x64, Linux arm64) — each job's bun install fetches the matching native binding. If you need a binary for a platform other than your host locally, run bun build --compile on that target's OS or wait for a GitHub Release.
Implementation plan in docs/specs/CLI_IMPLEMENTATION_PLAN.md (local-only, gitignored); preflight results in docs/specs/cli-preflight-results.md (also gitignored).