appstrate
v1.0.0-beta.67
Published
Official CLI for Appstrate — install, login, and manage your instance
Maintainers
Readme
Appstrate CLI
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.
Install
One-liner (recommended)
curl -fsSL https://get.appstrate.dev | bashDetects your OS/arch, downloads the matching binary of the release the served installer is pinned to (with APPSTRATE_VERSION=latest, the tag named by the minisign-signed channel manifest https://get.appstrate.dev/channels/latest.json) from GitHub Releases, drops it at ~/.local/bin/appstrate (rootless default; override with APPSTRATE_BIN_DIR), 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.
Alternate install paths
# 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).
Commands
| 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 space | List, switch, or create spaces pinned on the active profile. |
| appstrate code | Sync your spaces' skills, and the pinned space's agents as commands, into Claude Code and Codex. |
| appstrate packages | Edit a package in a local folder, push it to its draft, publish it. |
| 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 — a package id runs on the pinned instance, a .afps/.afps-bundle path runs in-process. |
| appstrate runner | Install and manage the Firecracker runner daemon on a KVM host. |
All commands accept --profile <name> to target a specific profile (see Profiles).
appstrate install
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). |
| --run-adapter | docker\|firecracker | Agent execution backend (default docker). firecracker runs each agent in a microVM on a KVM host running the appstrate-runner daemon; Docker tiers only. Also APPSTRATE_RUN_ADAPTER. Writes RUN_ADAPTER / MODULES / FIRECRACKER_RUNNER_*. |
| --runner-url | url | Firecracker (remote): URL of an existing appstrate-runner daemon, e.g. http://10.0.0.9:3100. Implies the remote topology. |
| --runner-token | token | Firecracker: shared bearer token for the runner daemon (default: generate one). |
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 | Recommended default. Adds Redis (BullMQ, rate-limiter). | | 3 | Docker | PostgreSQL + Redis + MinIO | S3 | Advanced: Tier 2 plus bundled MinIO object storage. |
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.
Lifecycle commands
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.
appstrate login
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 space + 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. |
| --space | <id> | After the org pin, pin this space on the profile non-interactively. Fails if the reference does not match any space. |
| --create-space | <name> | Create a new space with this name after login and pin it. Skips the cascade's default-space pick. |
| --no-space | — | Skip the post-login space-pinning step entirely. Subsequent calls must carry -H 'X-Space-Id: …', or pin later via appstrate space switch. |
Org pinning after login: 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 space + 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.
Space pinning after login: after the org pin succeeds, the CLI cascades into GET /api/spaces and branches:
- Exactly one space → auto-pin.
- ≥2 spaces → auto-pin the one with
isDefault: true(the server provisions exactly one default per org). No interactive picker at login — useappstrate space switchafterwards for a different space. - No default among ≥2 spaces (defensive — should never happen) → warn on stderr, leave unpinned.
On success, the banner names both: Logged in as … to "Acme" (org_xxx) / space "Default" (spc_xxx). The pinned space id is sent as X-Space-Id on every appstrate api call, so space-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. a space-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. - 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.
appstrate logout
Revokes the selected profile's refresh-token family server-side (POST /api/auth/cli/revoke) and wipes the local keyring entry + profile from config.toml.
appstrate logout
appstrate logout --profile prodFlags
| Flag | Values | Description |
| ----------------- | ------ | ------------------------------------------------------------------------------------------------------ |
| -p, --profile | name | Profile to log out from (resolved through the usual flag, environment and default-profile precedence). |
If the instance is unreachable, local credentials are still wiped (with a warning on stderr). Skill-cleanup failures do not skip credential removal either. An OS keyring that refuses to release its copy does not skip them either, but it is the one failure that changes the exit code: the profile, the credentials file and the skills are cleaned up all the same, and the command then ends non-zero — a token the store still holds is a usable credential, so a script chaining on appstrate logout must be able to tell that logout from a completed one. APPSTRATE_ALLOW_PLAINTEXT_TOKENS=1 is the exception: that profile never had a keyring entry to remove, so the refusal is not a failure and the command exits 0.
Logout also cleans the skill installations recorded as belonging to that profile: the generated Claude plugin becomes the setup plugin, without business skills or .mcp.json, and managed skills in shared targets are removed. Logging out another profile leaves the current installation alone. Cleanup also runs when credentials are already absent; failed deletions remain recorded so repeating appstrate logout --profile <name> can retry them. Unmanaged files and targets recorded under a different home directory are preserved.
This immediately changes the generated source directory, not an already loaded Claude session. Run claude plugin update appstrate@appstrate, then start a fresh session to apply the installed copy. The next marketplace sync follows the active profile: deleting the default profile may select another configured profile and install its skills; without a configured profile, the setup plugin remains.
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).
appstrate self-update
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 the newest release
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 — resolves the newest release from the signed channel manifest (
https://get.appstrate.dev/channels/latest.json+.minisig, verified with minisign before it is read; any failure is fatal —--releaseskips it), 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 appstrateinvocation. - unknown channel — emits diagnostic instructions.
Channel matrix and recipes: docs/cli/upgrades.md.
appstrate doctor
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.
appstrate whoami
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: [email protected]
Name: Alice
Expires: 2026-04-25T00:36:40.285ZExits 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.
appstrate token
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.
appstrate org
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 space + 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 space pin follows the org pin. Every org switch / org create clears the current spaceId and re-pins the new org's default space 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 Space '<id>' not found in this organization (apps/api/src/middleware/space-context.ts:159) — the message the Troubleshooting section below indexes by.
appstrate space
Manage the space pinned on the active profile. login auto-pins the default space in the pinned org (see above); space switch / space create let you change the pin without re-running the device flow. The pinned space id is sent as X-Space-Id on every appstrate api call — required for space-scoped routes (agents, runs, schedules, api-keys, notifications, packages, integrations, end-users, uploads, files). /api/webhooks is not one of them — it takes an explicit spaceId field instead.
appstrate space list # enumerate spaces in the pinned org; pinned row is marked *, default row tagged [default]
appstrate space switch # interactive picker (current space pre-highlighted)
appstrate space switch spc_xxx # non-interactive — by id
appstrate space current # print the pinned spaceId (scripts / shell prompts)
appstrate space create # interactive (name) → auto-pin
appstrate space create "Staging" # non-interactive → auto-pinAll four subcommands respect the global --profile <name> flag and talk to GET /api/spaces / POST /api/spaces. Spaces are identified by id only (there is no slug column server-side).
Subcommands
| Subcommand | Purpose |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| space list | List the spaces in the pinned org. The pinned one is marked with *; the org's default is tagged [default]. |
| space switch [id] | Re-pin the active space on the profile. With no argument, show an interactive picker with the current one highlighted. |
| space current | Print the pinned space id to stdout. Exits 1 with a hint when no space is pinned — designed for if / shell prompts. |
| space create [name] | Create a new space and pin it. With no argument, prompt for a name interactively. |
appstrate code sync
Materialize the union of skills placed in the spaces you reach in the pinned organization as Agent Skills directories on this machine — one Claude Code plugin, and/or the shared skill directories Claude Code and Codex scan directly. The connected Claude Code plugin also configures the organization's Appstrate MCP server, and installs every agent active in the pinned space as a command, /appstrate:run-<agent>, that launches it through that server (see Agent commands).
The command is designed to run unattended. Claude Code plugin marketplaces accept a command source: a locally installed tool prints the path of a directory holding a complete plugin, and Claude Code re-runs that command at install, then once per session in the background, reinstalling and reloading the plugin when the directory's content hash changes. That is the whole auto-sync mechanism — no daemon, no server-side change; the plugin's one SessionStart hook only prints the session notice (below).
appstrate code sync --target claude-plugin # → the Claude Code plugin directory
appstrate code sync --target codex # → ~/.agents/skills/
appstrate code sync --target claude-user # → ~/.claude/skills/
appstrate code sync --target claude-plugin --target codex # both, as the marketplace command does
appstrate code sync --target claude-user --source draft # sync working copies instead of published versions
appstrate code sync --target claude-plugin --space spc_prod --space spc_team # narrow this run; MCP stays on the pinned space
appstrate code sync --target codex --dry-run # report what would change, write nothing
appstrate code sync --target claude-plugin --print-path # the plugin path as the ONLY stdout lineCommand and flags
| Flag / command | Purpose |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| code sync | Reads the skills ACTIVE in every space this profile is a member of — a skill switched off in a space is not synced from it — and writes one directory per skill into each requested target. With claude-plugin, it also writes one command per agent ACTIVE in the pinned space. |
| --target <name> | Required, repeatable — there is no default target, and a bare appstrate code sync exits 1 with a usage line naming the three: claude-plugin → $XDG_DATA_HOME/appstrate/claude-plugin/; codex → ~/.agents/skills/; claude-user → ~/.claude/skills/. |
| --space <id> | Repeatable. Narrows this sync to the named spaces — IDs or unambiguous exact names from appstrate space list. Does not change the pinned MCP space and does not persist. |
| --source <name> | published (default) syncs each skill's latest published version, integrity-verified. draft syncs the working copy instead — for authors iterating on their own machine: the sync NAMES the draft, and naming it needs skills:write on the skill in its home space, so the instance refuses it (403 draft_not_writable) to everybody else rather than quietly handing back the published bytes. |
| --print-path | Print the plugin directory as the only stdout line; every message goes to stderr. This is what a marketplace command source consumes. Requires --target claude-plugin, and refuses --dry-run. |
| --dry-run | Print the per-target plan and write nothing. One line per target with the counts (+N new, ~N refreshed, =N unchanged, -N removed), then one line per affected slug: +, ~ or -. Cannot be combined with --print-path: a dry run produces no plugin, so there is no path worth printing. |
Space selection: your access is the selection. By default sync takes the ACTIVE skills of every space you are a member of, with permission to read skills there. Being added to a space puts its skills on your machine at the next sync; being removed takes its skills off again, keeping any that another space you are still in also supplies. No second step, and nothing to edit.
appstrate space list shows slightly more than that: an organization also lists its closed spaces to every member, so you can ask to be added to one. Those are not skill sources — the server refuses to serve a space you have not joined — and sync skips them silently rather than failing.
--space and syncSpaces narrow that set. They never widen it: a space you have not joined cannot be synced.
appstrate code sync --target claude-plugin --space spc_prod --space spc_team # this invocation onlyFor a persistent restriction, add IDs to the existing profile in config.toml (see the complete example below):
syncSpaces = ["spc_prod", "spc_team"]Changing organization clears this list because space IDs belong to one organization. Removing syncSpaces restores the every-space-you-are-in default. Setting syncSpaces = [] writes no business skills while retaining the MCP connection. A configured ID this profile can no longer use — one that is gone, or one you are only listed for and have not joined — is skipped with a note on stderr saying which, and the other spaces still sync; a --space flag naming nothing, or naming a space you cannot use, fails the run instead, because you typed it just now. An organization that has revoked this profile's access to its spaces entirely leaves no source at all: the sync removes every skill it installed from it, or fails the run when --space was typed. The pinned space remains required for the plugin's MCP connection even when the skills come from other spaces — so if you lose your membership of the space you pinned, sync keeps working on the spaces you are still in and warns you on stderr: the skills stay current, but the plugin's MCP server keeps naming that space and the server refuses it until you run appstrate space switch.
One copy per package, at the organization's latest. A package is an organization-level row that space_packages attributes to any number of spaces, so the same skill is normally listed by several of the spaces you reach. It is downloaded once, from the first that listed it. That choice never decides which version: versions/latest resolves per organization, so what lands on your machine is the organization's latest published version, whichever space listed it first. It can still be newer than what an agent in that space executes — an agent resolves its skills against the semver ranges in its own manifest, and each run freezes what it resolved.
One active context per destination. Profiles do not accumulate installations. A successful sync for another profile or organization replaces the previous context in the requested targets. The pinned space is not part of that identity: appstrate space switch installs the same skills and only rewrites the plugin's .mcp.json and agent commands, so it never triggers the replacement rules below. The plugin switches its skills, agent commands and MCP configuration together after the replacement has been prepared. Network, authentication or preparation failures during a context switch retain the previous plugin. Installed skill folders are generated copies: local edits are not sent back to Appstrate. Package authoring and bidirectional folder sync are outside this command.
Registering it with Claude Code. Add the marketplace, then install the plugin — the consent screen shows the exact command string Claude Code will re-run each session:
appstrate login
claude plugin marketplace add appstrate/claude-plugins
claude plugin install appstrate@appstrateThe recorded command string is:
if command -v appstrate >/dev/null 2>&1; then exec appstrate code sync --target claude-plugin --target codex --print-path; else exec npx -y appstrate@latest code sync --target claude-plugin --target codex --print-path; fiIt must stay byte-stable: changing it stops the background re-runs until the user re-accepts via claude plugin update appstrate@appstrate — so a change ships with the CLI release that introduces it, and is announced in the CHANGELOG. Skills then appear as /appstrate:<skill>, agents as /appstrate:run-<agent>.
Fresh machine. Installing the plugin is the only step that has to come first. The command uses the installed CLI when there is one and npx otherwise, and --print-path on a machine whose profile is not configured (or has no org / space pinned) still succeeds: it installs a plugin whose only skill, /appstrate:setup, states what is missing and the exact command to run, and the session notice (below) says so at every session start — to the user, and to Claude so it can offer to run appstrate login itself (the CLI opens the browser; the user only approves there; login pins the single organization and its default space by itself). The first connected sync replaces that skill with the organization's. Outside explicit logout, this only happens on a fresh plugin: once skills have been synced, a lapsed login fails the run, leaves the installed plugin untouched, and writes the session notice. Explicit logout performs the cleanup described above.
Session notice. Every generated plugin has one SessionStart hook that prints $XDG_DATA_HOME/appstrate/skills-sync/notice.json, if present, and never fails. A run with --target claude-plugin (not --dry-run) writes it, with the run's time and the command that fixes the problem, when the run fails on a lost login, a missing profile or pin, or a typed --space on a revoked organization, and when a sync that went through finds an unusable pinned space or a revoked organization; a fresh setup plugin and appstrate logout write the untimed setup notice with the login command. A plugin sync without such a problem deletes it; a network error, a 5xx or a CLI that cannot start leaves it alone. It shows at the next session start; after the fix, claude plugin update appstrate@appstrate re-runs the sync and a new session loads the result.
MCP connection. A connected sync writes this .mcp.json at the plugin root, using the profile's instance, pinned organization and pinned space:
{
"mcpServers": {
"appstrate": {
"type": "http",
"url": "https://app.example.com/api/mcp/o/org_123abc/s/spc_456def"
}
}
}It contains no tokens. The setup plugin has no MCP configuration. In Claude Code, open /mcp, select plugin:appstrate:appstrate, and complete the browser OAuth flow if authentication is needed. This login is separate from appstrate login; the CLI's keyring session is never copied into the plugin. Tools use names such as mcp__plugin_appstrate_appstrate__search_operations. Claude Code plugin MCP reference.
MCP stays on the active space. The URL's /s/<space> segment pins MCP operations to the CLI's pinned spaceId, independently of --space and syncSpaces. For example, skills selected from Production and Team still execute MCP operations in Production when Production is pinned. Synchronizing a skill from another space does not route its tools to that space. The server validates the space against the organization and your role in it: a pinned space that no longer exists, or where you lost your role, refuses every MCP call until appstrate space switch re-pins one and the next sync rewrites the file. Claude Code keys an MCP login on the server's URL, so a space switch asks for the OAuth login again once. The organization's URL alone (a manual claude mcp add, see below) is not pinned: it reaches every space you hold a role in, and each of its tool calls names the space it acts in (space_id); appstrate space list shows those spaces.
Upgrading or switching organizations or spaces. The first sync after this CLI upgrade changes the plugin's content hash even if the skills are unchanged. Switching instance, organization or space also rewrites the connection. To apply it immediately:
appstrate org switch <id-or-slug> # when changing organizations
appstrate space switch <id> # when changing spaces
claude plugin update appstrate@appstrate # re-runs the default-profile syncThen exit the active Claude Code session and start a new one, check the endpoint in /mcp, and authenticate for the new endpoint if requested before running an operation. Use this restart sequence for the first MCP upgrade too. Do not rely on /reload-plugins alone to switch organizations, spaces or instances: the active connection can retain the previous endpoint or header. For a different instance, connect the marketplace's default CLI profile to it with appstrate login --instance <url> first. A one-off sync with --profile is replaced by the default profile on the next marketplace refresh. The exact prompts depend on Claude Code's version, saved approvals and enterprise settings; a plugin update is not proof that OAuth or the endpoint switch succeeded. Plugin component lifecycle.
Existing MCP connections and opt-out. A manually configured appstrate server can coexist with the plugin server and expose another set of tools. Check their endpoints in /mcp and disable the connection you do not want; sync never removes your manual configuration. To keep the skills without the plugin's MCP connection, toggle plugin:appstrate:appstrate off in /mcp for the current project. Disabling a server. Administrators can restrict the endpoint with allowedMcpServers / deniedMcpServers URL rules, or match the scoped server name plugin:appstrate:appstrate rather than the bare appstrate key. Managed MCP configuration.
What lands in a skill directory. Every file of the published artifact except manifest.json and RECORD (Appstrate packaging, not skill content), with one rewrite in SKILL.md: the frontmatter name is pointed at the directory name when it differs, because the spec requires the two to match. Nothing else is touched — no description is invented, no key is reordered. The output is deterministic — no timestamps, no sync metadata — because a mode: "copy" plugin's version is the hash of its contents.
Appstrate refuses to publish a skill whose frontmatter is not valid Agent Skills YAML, but artifacts published before that rule existed are still synced exactly as authored. Each one is named once on stderr (… does not pass the skill frontmatter rule …): Claude Code and Codex may skip it, and the fix is to republish it from Appstrate.
Directory names. The Agent Skills spec requires the frontmatter name to equal the parent directory name, and @scope/name is not a legal skill name, so the directory is the frontmatter name when it is legal, else the slugified package name segment. An installed name stays with its package while the catalogue still lists it, even when this run could not read it; it changes owner only when its holder leaves or renames itself. A newcomer claiming a held name becomes <scope>-<name>, then -2, -3, … Every rename is reported on stderr.
Failure modes. The command never prompts and never assumes a TTY. Each of these is a whole-run failure: it exits 1 with a one-line remedy on stderr, which Claude Code surfaces under /plugin → Errors; the actionable ones also write the session notice (see above). The first, fourth and fifth rows become the /appstrate:setup plugin instead under --print-path on a fresh plugin (see above).
| Condition | stderr |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Not logged in (or profile not configured) | Profile "default" not configured. Run: appstrate login --profile default --instance <url> |
| No credentials (never stored, or refresh token expired) | No credentials for profile "default". Run: appstrate login --profile default --instance https://app.example.com |
| Session revoked (invalid_grant) | Session for profile "default" is no longer valid (invalid_grant). Run: appstrate login --profile default --instance https://app.example.com |
| No organization pinned | No organization pinned. Run: appstrate org switch <org-id-or-slug> --profile default |
| No space pinned | No space pinned. Run: appstrate space switch <space-id> --profile default |
| --print-path without the plugin target | --print-path prints the Claude Code plugin directory. Add: --target claude-plugin |
| --print-path together with --dry-run | --print-path cannot be combined with --dry-run: a dry run writes no plugin. |
| The catalogue call, a target write or the state file failed | the underlying error |
Per-skill failures within the same installed context are graded differently under --print-path. A skill that was never published, whose bytes do not match the server's X-Integrity, or whose destination is not ours is reported on stderr and skipped; the rest of the sync completes.
- Without
--print-path, the command then exits 1 so a wrapper notices. - With
--print-path, it exits 0 as long as the plugin tree itself was written (or was already up to date), and prints the path. A non-zero exit makes Claude Code discard the run, so failing the process over one unpublished skill would throw away a correct plugin. When the plugin tree could not be produced, nothing is printed on stdout and the command exits 1.
A skill whose refresh fails keeps the version already on disk and its state entry is preserved — a failed download never deletes a working skill. The same holds one step earlier: deletion is decided against the catalogue, not against what resolved. A skill the server still lists but whose version could not be read (a 500, a 429, an expired token mid-run) is kept exactly as it is, counted among the unchanged; only a skill that has genuinely left the catalogue is removed. A draft you cannot write (--source draft, 403 draft_not_writable) is the exception: it is refused the same way on every run, so it is reported and removed rather than kept, and it does not block a context switch.
Deleting, and not overwriting. codex and claude-user write into directories you also fill by hand. The sync only ever removes — or replaces — a directory its own state file records as managed for that target. A destination it does not own is left completely alone and reported:
Skipped @acme/pdf-tools on codex: /home/you/.agents/skills/pdf-tools exists and is not managed by appstrate — remove or rename itThere is no automatic rename: remove or rename the directory yourself and re-run. While a sync is in flight, staging happens under a dot-prefixed .appstrate-staging/, so neither Claude Code nor Codex can pick up a half-written skill. Concurrent syncs and logout cleanup are serialized by a flock(2) lock under skills-sync/. The kernel releases it when its descriptor closes or the process exits; the lock file is never removed. Where the platform or the filesystem has no working flock(2) — Windows, some NFS, 9p and virtiofs mounts — or the lock file cannot be created or opened, the sync runs unlocked and says so on stderr.
Ownership is recorded per target together with its profile context — profile name, instance, user and organization, and not the pinned space, which is only .mcp.json content — and the root it was written under. HOME is not a constant — the same profile run from cron, launchd, sudo -E or a devcontainer can resolve a different ~/.agents/skills — so a state file whose recorded root does not match the current one is read as claiming nothing. Every directory it finds is then treated as unmanaged: refused, never overwritten. A state file that does not name its context is refused the same way, for the same reason: nothing can say whose those directories are. That is what a ledger written by v1.0.0-beta.56 or .57 looks like — claude-plugin is regenerated whole and costs you nothing, while skills already synced into codex or claude-user are reported unmanaged once and have to be removed by hand before sync will own them again.
Agent commands
With the claude-plugin target, sync also installs one command per agent ACTIVE in the pinned space, system agents included. Type it with your request, or just ask in plain words and let Claude pick the command; Claude builds the input, launches the agent through the plugin's MCP server with run_and_wait, and reports the result and every file it produced:
/appstrate:run-invoice-extractor extract the totals from ~/Downloads/march-invoice.pdfWho gets them. Agents come from the pinned space alone — --space and syncSpaces never change it — because an MCP session, its uploads and its runs belong to one space. You need agents:run, runs:read (or runs:read-all) and mcp:invoke there (the runner role has them); otherwise sync installs none and says so on stderr. codex and claude-user never get agent commands: nothing configures the MCP server there.
What a command contains. skills/run-<agent>/SKILL.md and input.json, the space's launch contract — the dashboard launch form's split into prompted fields (taken from your request; Claude asks only for a missing required one), prefilled (sent only when you override them) and locked (never sent). Stored values are never written to disk. Its description ("Launches a metered run of the Appstrate agent …") sits in each session's context, so Claude can choose it on its own.
The run asks for permission. Whoever picks the command, nothing is pre-approved: Claude Code's prompt for run_and_wait shows the exact input before the metered run, unless you allowed the tool permanently or run in an auto-accept mode. A command never launches twice once a run exists.
File inputs. Claude uploads a local file with curl and passes its upload:// URI. Keep the file in the session's working directory, or start Claude Code with --add-dir <dir>: elsewhere, shell access prompts, or the file is copied through the model's context.
Keeping commands current. A command pins the version it launches (--source published: the latest published; --source draft: draft, removed if you cannot write the agent; a system agent always uses its published version). A republish, a schema, lock or default change, or another pinned space rewrites it at the next sync. A command that fell behind says so at launch and tells you to run claude plugin update appstrate@appstrate, which re-runs the sync and reinstalls the plugin.
Names. run-<name>, under the skill directory rule above: an installed command keeps its name while its agent is still active, a newcomer falls back to run-<scope>-<name>, -2, …, and skills are named before agents.
Codex, and running without a Claude Code plugin
The recorded marketplace command syncs both targets (--target claude-plugin --target codex), so if you use Claude Code, every session already refreshes ~/.agents/skills/ and Codex picks the skills up on its next start. This target supplies skills only — no agent commands, which need the plugin's MCP server — and it does not configure Codex MCP.
Connect Codex manually. Run appstrate whoami to read the instance URL, appstrate org list to choose an organization, and appstrate org current to print its pinned ID. Substitute those values below, removing any trailing slash from the instance URL:
codex mcp get appstrate # inspect any existing entry first
# If no entry exists:
codex mcp add appstrate --url https://app.example.com/api/mcp/o/org_123abc
codex mcp login appstrateIf appstrate already names the intended endpoint, keep its configuration and log in only if needed. If it names something else, use a distinct name such as appstrate-acme in both commands to preserve the existing connection. On an organization or instance switch, update only the intended entry's url under [mcp_servers.<name>] in ~/.codex/config.toml, preserving its other settings, then run codex mcp login <name>, restart Codex and verify /mcp before using it. Skill sync does not update this URL or share the CLI's login. This organization URL reaches every space you hold a role in and each tool call names its space_id; to pin the connection to the CLI's space instead, append /s/<spc_id> to the URL (appstrate space current prints the id). Codex MCP configuration.
For Claude Code without the plugin, use the same instance and organization with an explicit user scope, then authenticate through /mcp:
claude mcp add --transport http --scope user appstrate https://app.example.com/api/mcp/o/org_123abc/s/spc_456def
# drop "/s/spc_456def" to reach every space you hold a role inInspect an existing entry with claude mcp get appstrate before adding; keep it or choose another name rather than replacing it blindly. Claude Code installation scopes.
Two cases need you to run the sync yourself: you do not use Claude Code at all, or your organization blocks command-sourced plugins (disableCommandPluginSources). Then schedule appstrate code sync --target codex (add or swap in --target claude-user to feed ~/.claude/skills/, which Claude Code picks up live).
A cron entry every 15 minutes — cron's PATH is minimal, so give the absolute path:
*/15 * * * * $HOME/.local/bin/appstrate code sync --target codex >/dev/null 2>&1On macOS, prefer a launchd user agent running the same command every 900 seconds (command -v appstrate gives the absolute path).
The scheduler must reach your OS keyring. The sync reads the profile's token from the keyring, and a keyring is bound to a session, not to a user id: a launchd user agent runs inside your logged-in session and can unlock the login keychain, while a system cron job (or anything under sudo) may not — the sync then fails with the "not logged in" remedy even though you are. That is why launchd is the recommended path on macOS. Remember that HOME differs per context too: a scheduler resolving a different ~/.agents/skills than your shell does gets a state file that claims nothing, and every directory there is refused rather than overwritten.
Per-skill toggles survive all of this. We never write ~/.codex/config.toml, and directory names are stable across syncs, so a [[skills.config]] block disabling one of the synced skills keeps applying to the same skill after the next sync.
appstrate packages — pull, status, push, publish
Edit a package (skill, agent, integration, MCP server) in a local folder with any tool, then write it back to its draft and publish it as a separate, deliberate step. appstrate code sync is the other direction: it copies what is published (or, with --source draft, your draft) into your coding tools and never sends anything back.
To try a skill before publishing it, even before pushing it, load its folder straight into Claude Code: claude --plugin-dir <dir> exposes it as /<folder>:<name> for that session, and /reload-plugins picks up edits. push prints that command for the folder it pushed. If the published version is installed too (/appstrate:<name> or a synced /<name>), the session holds both, so invoke the draft by its full name. To test whether Claude picks the skill on its own from its description, hide the published copy first: disable the appstrate plugin, or move a claude-user copy out of ~/.claude/skills/. code sync --source draft is not the way to try one skill: it swaps every synced skill for its draft.
appstrate packages pull my-skill # the draft → <workDir>/<org>/packages/skills/@<org>/my-skill
appstrate packages status my-skill --diff # what the folder would change in the draft
appstrate packages push my-skill # the folder → the draft; nobody else sees it yet
appstrate packages publish my-skill # the draft → a version every space resolvesAuthority is the package's home space. A package lives in one space, its home, and only a caller who may write there can read its draft, push or publish. Anyone else who can read the package pulls its latest published version, read-only, and push refuses with a pointer to co-editing. The CLI asks the platform where the package lives (GET /api/packages/{scope}/{name}/home, across every space you reach), so a package homed in your personal space is found even while a team space is pinned. Sharing and activation are not part of this loop.
The draft version belongs to the folder. pull of a draft records the draft's ETag for that folder (by its real path), and push sends it back as If-Match: a draft edited anywhere else in between (the dashboard, the chat, the API, another folder or machine) is refused with 412 and nothing is written. After a push the folder records the ETag its own write produced, never a later one it did not see. A lock table written by an older CLI (numeric locks) is refused with the steps to rebuild it. status warns about it before you push. A folder that never read the draft, or that pulled a published version, can only push with --force, which replaces the draft with the folder. Lock records are per profile and updated under an flock(2), so two commands never lose each other's entries.
| Subcommand | Purpose |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| pull <package> [dir] | @scope/name or a bare name under the org's slug, optionally @<spec>: <package>@1.2.0, @latest, a range or a tag pulls that published version instead of the draft; @draft pulls the draft, and is refused to someone who cannot write it. Refuses a non-empty folder unless --force, which makes the folder mirror the package: files it does not have are deleted, and listed. |
| status <dir> | A folder path, or a bare name resolved in the work dir. manifest.json counts: a configuration-only edit is a change. --diff prints a line diff for each modified text file. |
| push <dir> | Writes every change in ONE atomic PATCH /api/packages/{type segment}/{scope}/{name}: all of it lands, or none. Bytes that are not UTF-8 text travel as base64, so binary annexes survive. |
| publish <package-or-dir> | Creates a version from the draft, by the dashboard's rule. Given a folder, refuses when the folder holds anything the draft does not (unpushed edits, or a draft moved since the folder read it); given an id, publishes the draft as it is. The rule: a draft still at the latest version is bumped (--bump patch\|minor\|major, default patch), a draft ahead of it is cut as is, one behind it is refused. A specific number goes in manifest.json, pushed, as in the dashboard. The publish carries the ETag it read as If-Match, so a draft that moved in between is refused. |
What the folder is. Dot-named entries (.git, .env, .DS_Store, editor state) and __pycache__ are tooling state and secrets, not package content: never read, pushed, deleted from the draft or reported — on both sides, so a dotfile the draft holds is left alone — and pull never writes them either, so a package cannot plant a .git/config or an editor task in your folder (it lists what it skipped). node_modules is NOT ignored: an MCP server bundle ships server/node_modules as part of the package. A file the folder writes (added or modified) is at most 1 MiB, the platform's per-file write limit, checked before the write is sent; an unchanged larger file the draft already holds (an import is bounded only by the archive) is fine. pull refuses a package holding two paths that are one file on a case- or normalization-insensitive disk (A.md and a.md). a symbolic link is refused rather than followed. A folder without manifest.json is a skill (SKILL.md alone): its manifest is not compared or sent, and stays the draft's. push never changes the version — that is decided when you publish, as in the dashboard; a publish that bumps it carries the new version into the manifest.json of every folder current with the draft, leaving the rest of that file alone.
Creating a package publishes it. push on an id that does not exist yet refuses unless you pass --create. It then zips the folder — listing on stderr the ignored entries it leaves out — in
