Dispatch a Mission
CONV_ID=$(ls ~/.claude/session-env/ | head -1)
pylot dispatch "<task>" --agent <team>.<role> --repo <org/repo> --context "conversation_id=$CONV_ID"
Always pass --context conversation_id=… for auto-wake. Prompt limit: 4 KB — put full specs in issue comments.
Monitor Missions
pylot missions list # recent missions
pylot missions view <job-id> # full detail
pylot missions logs <job-id> --follow # tail CloudWatch logs
Terminal statuses: done, failed, cancelled, timeout.
Secrets Discovery
pylot secrets tree # full secrets tree
pylot secrets get <path> # bundle keys + fingerprints (no values)
Load into conversation: pylot conversations resources-add <conv-id> type=secret ref=pylot/<path>
Assets
You do not have S3; you have the assets API. Anything durable — a screenshot, a
long report, a recording — has to go through pylot assets, never a direct
write and never a link to somewhere else. Never raw.githubusercontent.com on
a feature branch, never a static S3 key: both rot once the branch or retention
window is gone — see the Hosting and durability
rule
for the receipts.
Three lifetimes — pick the one that matches what you're making, before you make it:
| Lifetime | What it's for | How |
|---|---|---|
| Ephemeral scratch | A draft you're still iterating on in this turn | nothing — stays in-turn |
| Resumable checkpoint | A private draft/report that must survive a turn ending | presign --conversation <id> → PUT → finalize (finalize takes only --sha256/--size, no scope flag) |
| Published artifact | Evidence meant for a human or another repo (screenshot, PR proof) | the full presign → finalize → publish recipe below |
Do not reach for publish --conversation <id> as a shortcut or retry path for a
checkpoint — it sets visibility: public, which is a privacy regression for
what is meant to be a private draft. Ownership is already fixed at presign
time via --conversation <id>; there is no separate "attach to conversation"
step for a checkpoint.
A checkpoint's retention is not tied to evidence_class — there is no special
TTL exemption for it. retention_policy already defaults to indefinite, so
passing it explicitly changes nothing. That said, in staging every asset (any
class, any retention_policy value) still auto-expires after 30 days — an
S3 object-lifecycle rule outside the API, with no per-asset override. Treat a
staging checkpoint as staging-ephemeral: good for surviving a turn boundary,
not a substitute for promoting the finished artifact once the report is done.
See Retention
for the full policy.
Ephemeral scratch is not durable: a turn can end more abruptly than a normal
function return drops it (see the Lambda Freeze
Convention
for that failure shape in miniature). Concrete trigger, don't wait for a
vaguer sense of "running low": if a context capacity system message shows up
in the conversation (fires at ~90% of context, per issue #944), that turn is
your last chance — checkpoint what you have as a private conversation asset
before you do anything else. If no such message has fired yet but you're
about to end a turn with a report still incomplete, checkpoint anyway; the
warning is a backstop, not a permission slip to wait for it.
Use the CLI for the complete Pylot asset lifecycle. The only operation outside
the CLI is the direct PUT to the short-lived presigned object URL; never call a
Pylot gateway asset endpoint with curl.
FILE=/path/to/evidence.png
CONTENT_TYPE=image/png
SIZE=$(wc -c < "$FILE" | tr -d ' ')
if command -v sha256sum >/dev/null 2>&1; then
SHA256=$(sha256sum "$FILE" | awk '{print $1}')
else
SHA256=$(shasum -a 256 "$FILE" | awk '{print $1}')
fi
# Choose the ownership scope required by the destination: --repo, --job, or --conversation.
PRESIGN=$(pylot assets presign \
--content-type "$CONTENT_TYPE" --size "$SIZE" \
--repo <org/repo> --filename "$(basename "$FILE")" \
--evidence-class visual --retention-policy <repo-policy>) # use the retention policy required by the owning repo (e.g. indefinite, 90d)
ASSET_ID=$(printf '%s' "$PRESIGN" | jq -r '.asset_id')
UPLOAD_URL=$(printf '%s' "$PRESIGN" | jq -r '.upload_url')
# The presigned URL carries its own credentials; do not add a Pylot auth header.
curl -fsS -X PUT "$UPLOAD_URL" -H "Content-Type: $CONTENT_TYPE" --data-binary @"$FILE" && \
pylot assets finalize "$ASSET_ID" --sha256 "$SHA256" --size "$SIZE"
visual is the evidence class for screenshots and recordings intended as
durable review evidence. Use the retention and evidence policy required by the
owning repo; do not class a transient conversation image as permanent evidence
unless that is intentional.
After finalization, use the verb that matches the destination:
pylot assets view "$ASSET_ID" # metadata + temporary GET URL
pylot assets attach "$ASSET_ID" --mission "$PYLOT_JOB_ID" # private mission evidence
pylot assets publish "$ASSET_ID" # public capability URL
pylot assets publish "$ASSET_ID" --conversation <id> --alt "description"
pylot assets unpublish "$ASSET_ID" # revoke public capability URL
Publishing returns public_url. Start with ordinary publish. Add
--override-policy only when the owning policy explicitly calls for an audited
override or the server returns the corresponding policy restriction; never use
it preemptively to bypass policy. Conversation publishing
both publishes and attaches the asset; attach --mission records private mission
evidence without publishing it. All six lifecycle actions — presign, finalize,
view, attach, publish, and unpublish — remain gateway operations and must
go through pylot assets.
Team Settings
One command, one round-trip, visible proof — prints the value read back from the server after the PATCH (#3094):
pylot teams config <team> deploy.release_mode=ship # dotted-path set + read-back
pylot teams config <team> budget_daily_usd=600
pylot teams get <team> --fields deploy,cron # scoped read of stored config
pylot teams update <team> key=value [...] # multi-field PATCH (no read-back print)
Mutable fields (server-validated; a 400 lists the live set as valid_fields):
budget_daily_usd enabled org chains provider_chain cron fargate
fargate_size deploy operators worker_images repos distill_enabled
context_warn_pct context_hard_pct slack_channels.
Read-back guarantee: a field the server cannot persist is rejected with 400 —
never accepted-and-dropped (the round-trip corpus test enforces this; team-level
skills was removed from the set for exactly that reason — operator skills live
under operators.<role>.skills). If a write "succeeds" but reads back null,
that is a bug — file it; do not retry with creative payloads.
Workers — Spawn, Drive, Stop
A worker is a Fargate devbox running the target repo. Ownership is by scope:
spawn and list need exactly one of --mission / --conversation; the
per-worker verbs take --mission or fall back to the unscoped /workers/:wid
route. Verify flags with --help — image CLIs vary in age.
1. Preflight the repo
pylot devboxes project <org/repo> # → task_def{family,revision}, required_env_ok
No task_def means no image was ever built and the spawn boots into
CannotPullContainerError. Build first: pylot deploy build-worker <org/repo> --wait
(admin credential — an operator gets 403; see §6). Spawn failures are typed:
404 no_project/no_repo (repo not in any team's devbox config), 409 mission
already terminal, 422 provider_required, 502 ECS/secrets.
2. Spawn
# inside a mission — your own job
pylot workers spawn --mission "$PYLOT_JOB_ID" repo=<org/repo>
# from a conversation — name= is the human-readable pretty name (tag pylot:name)
pylot workers spawn --conversation "$CONV_ID" repo=<org/repo> name=<short-purpose>
201 → {worker_id, task_arn, last_status}. Boot runs PROVISIONING → RUNNING
(~1–2 min). Prompts are queued server-side and claimed once the in-container
daemon boots, so an early prompt is not lost; if you need RUNNING confirmed,
pylot workers list --mission "$PYLOT_JOB_ID" carries live ecs_status (the
single-worker view does not). name= is honoured on the conversation path only.
3. Drive
pylot workers prompt <wid> --mission "$PYLOT_JOB_ID" "<text>" # 202 {turn_seq} | 409 busy|stopped
pylot workers prompt <wid> --mission "$PYLOT_JOB_ID" --wait --timeout 3600 "<text>"
pylot workers view <wid> --mission "$PYLOT_JOB_ID" # turn_state, turn_seq, last_result, last_exit_code
pylot workers output <wid> --mission "$PYLOT_JOB_ID" # result text only
pylot workers logs <wid> --mission "$PYLOT_JOB_ID" # CloudWatch tail (--mission required)
The turn is done when turn_state is idle/error/reaped and turn_seq ≥
the seq the prompt returned. Only trust last_result then. --wait polls every
5 s up to 900 s (override with --timeout) and exits non-zero on a non-idle
terminal state; --follow streams container logs to stderr and requires --wait.
A devbox that dies mid-turn is reaped with last_exit_code: -1, so the loop
cannot hang forever. Between phases, read the output before sending the next
prompt — a failed phase should not be built on.
If --wait / --follow / output are absent from pylot workers prompt --help,
this container's CLI predates them: poll view on a sleep loop, or use §7.
In a chat/Lambda runtime there is no budget to block on --wait — never busy-poll.
Prompt, then schedule a wake (see Async Wake Pattern) and re-check view next turn.
4. Stop (destructive) and resume
pylot workers stop <wid> --mission "$PYLOT_JOB_ID" --force
pylot workers resume <wid> --mission "$PYLOT_JOB_ID"
--force is mandatory — the CLI refuses the stop without it. Ask a human before
stopping a box someone is working in. Stop is idempotent; always stop a mission
worker when the skill finishes (harvest-on-complete is the backstop, not the plan).
Conversation-owned devboxes snapshot on stop and resume restores that snapshot
with session_id preserved; mission-worker snapshots are opt-in and OFF by default
(cost control), so treat a mission worker's stop as final unless you know the flag
is on.
5. Multi-box work — you are the message bus
Boxes never talk to each other. Preflight and spawn one per repo with distinct
purpose names, then relay by hand: workers view A → extract the load-bearing
evidence (request id, stack trace, payload shape) → workers prompt B with it.
Keep relayed context small and factual; never pass secrets between boxes — each
box already carries its own bundle as env. Two boxes burn budget twice as fast,
so only spawn a second when both repos genuinely need code or log access.
6. What your credential can call
The capability gate fires only on the operator JWT a mission container runs on
(PYLOT_OPERATOR_TOKEN, aliased to PYLOT_DISPATCH_TOKEN). Operators hold every
operator capability, so the gate reduces to one question: is the route mapped at
all? Mission-scoped worker routes are; unscoped ones are not.
Exception — session JWT ($PYLOT_API_TOKEN): the following conversation-scoped routes
ARE mapped at org:member tier (route-capability.mts:224-229) and ARE reachable
with a session JWT — the capability gate passes them through, and each handler enforces
its own authorization server-side:
| Route | Since | Auth boundary |
|---|---|---|
POST /conversations/:id/slack-post |
#2622 | handler: org member |
POST /conversations/:id/slack-pickup |
#2622 | handler: org member |
POST /conversations/:id/admin-action |
#2891 | handler: org-admin check via turn-trigger |
POST /conversations/:id/admin-action/confirm |
#2891 | handler: org-admin check via turn-trigger |
These are the only conversation-scoped routes that accept a session JWT. All other
conversation-scoped routes and all /admin/* routes remain 403 for session JWTs — by design.
| Call | Mission operator | Devbox worker · local pylot auth login |
|---|---|---|
workers spawn/list/view/prompt/output/stop/resume/logs with --mission |
yes | yes |
the same verbs without --mission (unscoped /workers/:wid) |
403 | yes |
workers spawn/list --conversation |
403 | yes |
devboxes projects, devboxes project <org/repo> |
yes | yes |
devboxes spawn/view/connect/delete/list |
403 | yes |
deploy build-worker, deploy promote |
403 | yes |
deploy status <build_id> |
yes | yes |
A gate 403 reads {"error":"forbidden","reason":"capability_required","capability":"unknown"}.
Nothing outside the operator JWT is capability-gated, but handler-level org fences
still apply to org-scoped tokens. Inside a mission, always pass
--mission "$PYLOT_JOB_ID" — that one habit avoids every operator 403 above.
7. Fallback when there is no usable CLI
Only for containers whose image carries no pylot or one too old for the verb you
need. PYLOT_JOB_ID, PYLOT_API/PYLOT_GATEWAY_URL and PYLOT_DISPATCH_TOKEN are
set in both operator and mission-worker containers.
AUTH=(-H "Authorization: Bearer $PYLOT_DISPATCH_TOKEN" -H "Content-Type: application/json")
BASE="${PYLOT_API:-$PYLOT_GATEWAY_URL}/missions/${PYLOT_JOB_ID}/workers"
WID=$(curl -s --max-time 90 -X POST "${AUTH[@]}" -d "{\"repo\":\"$REPO\"}" "$BASE" \
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("worker_id",""))')
SEQ=$(curl -s --max-time 30 -X POST "${AUTH[@]}" -d "{\"prompt\":\"$PROMPT\"}" "$BASE/$WID/prompt" \
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("turn_seq",""))')
# poll GET "$BASE/$WID" until turn_state=idle AND turn_seq=$SEQ, then read last_output
curl -s --max-time 30 -X POST "${AUTH[@]}" "$BASE/$WID/stop" >/dev/null 2>&1 || true
Same routes, same semantics as §2–4. Prefer the CLI wherever it exists — one transport, one source of truth.
Org Setup From a Conversation (admin-action)
Requires pylot#2978 —
pylot convo admin-actionmust be present in the worker image before workers are directed here. Verify:pylot convo admin-action --helpmust succeed in the container.
Session JWTs carry scopes [dispatch, missions:read, heartbeat] — /admin/* routes
always 403 by design. /conversations/:id/admin-action is a separate authorization
boundary (see §6 exception table) that accepts session JWTs; the endpoint enforces
org-admin authorization server-side via the turn's triggering message sender.
Primary path
# auto-detects $CONVERSATION_ID from environment
pylot convo admin-action <op> [key=value ...]
# explicit conversation id override
pylot convo admin-action <op> --conversation <id> [key=value ...]
# confirm a staged (confirm-tier) operation after org admin replies
pylot convo admin-confirm <code>
# reject a pending confirm-tier operation
pylot convo admin-confirm <code> --reject
Operations
Op list is closed — any value not in this table returns 400. Never pass credentials or API keys in op args — the gateway returns 400 and nothing is stored (hard boundary: credential-looking args are explicitly rejected).
| op | tier | effect |
|---|---|---|
setup-status |
read (any conversation member) | onboarding state: App install, teams, repo bindings + worker-image presence, goals, operators/skills, provider chain, budget |
team-create |
immediate (org admin requester) | create a team in this org |
team-rename |
immediate (org admin requester) | rename a team |
repos-add |
immediate (org admin requester) | bind a repo to a team; response includes worker-image presence |
goals-set |
immediate (org admin requester) | set team goals (the store auto-pylot stage 00 reads) |
operator-add |
immediate (org admin requester) | create an operator on a team |
skill-assign |
immediate (org admin requester) | assign a skill to an operator |
instructions-set |
immediate (org admin requester) | set operator instructions |
org-update |
confirm | org settings (Slack default team, max_concurrent, …) |
budget-set |
confirm (spend) | team daily budget cap |
provider-assign |
confirm (model/spend routing) | attach an existing platform provider chain to org/team |
Confirm-tier flow
Confirm-tier ops (org-update, budget-set, provider-assign) are staged first,
then approved by an org admin's reply — the approver's identity is proven by their
own conversation turn, not supplied by the worker.
pylot convo admin-action <confirm-op> [args]→ gateway returns202 {code, summary}- Worker relays to the user: "An org admin must reply
confirm <CODE>within 10 minutes." (summarydescribes exactly what will change — relay it verbatim.) - The org admin sends a reply; the approval turn's own message sender proves their identity.
pylot convo admin-confirm <CODE>(called in the approval turn) →200, executed.- Rejected or expired:
pylot convo admin-confirm <CODE> --rejectkills the pending action; an expired code (>10 min) returns410and never executes.
Errors
| error | meaning | user-facing guidance |
|---|---|---|
no_human_trigger |
Turn has no human sender (scheduled wake-up, automation) | Retry from a conversation turn triggered by a human message |
unlinked_slack |
Sender's Slack account is not linked to a GitHub account | Ask the user to link their GitHub account in the Slack app settings |
not_org_admin |
Linked GitHub account is not an admin of this org | Immediate ops need an org admin in the thread; confirm-tier: anyone can stage, an org admin must confirm |
terminal_only |
Op would require a credential or secret value | Use the terminal CLI — credentials cannot transit the conversation |
code_expired |
Confirm code is older than 10 minutes | Re-run the original op to get a fresh code |
Transition-window curl fallback (pre-#2978 images only)
Use this only if the container image predates pylot convo admin-action.
$CONVERSATION_ID is set in chat-worker containers. Use $PYLOT_API_TOKEN
(session JWT) — NOT $PYLOT_DISPATCH_TOKEN (operator token 403s this endpoint
by design; using it here is the exact mis-behavior pylot#2979 corrects).
# immediate op
curl -s -X POST \
-H "Authorization: Bearer $PYLOT_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"op\":\"$OP\"}" \
"${PYLOT_API:-$PYLOT_GATEWAY_URL}/conversations/$CONVERSATION_ID/admin-action"
# confirm a staged op (after org admin replies with the code)
curl -s -X POST \
-H "Authorization: Bearer $PYLOT_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"code\":\"$CODE\"}" \
"${PYLOT_API:-$PYLOT_GATEWAY_URL}/conversations/$CONVERSATION_ID/admin-action/confirm"
Automations
pylot automations list # inventory with run stats
pylot automations get <name> # single rule detail
Per-repo coverage: filter only_repos/skip_repos from list output.
fellowship-dev/pylot admin procedures (dispatched by automations)
Scoped placement-doctrine exception: this skill operates the Pylot gateway itself (see the file description above), so the org name, endpoint shapes, and repo paths below are the skill's own subject matter, not another repo's policy leaking in — unlike
create-compelling-prsordouble-check, there is no playbook indirection to fall back to for "how do I operate the Pylot gateway." This file already carried extensive pylot-specific content before this section (dispatch, secrets, assets, automations commands above). Recorded here per review finding R1 on PR #170 so the exception doesn't re-trip as an unresolved doctrine violation on future passes.
These four fellowship-dev/pylot automations dispatch to pylot.lead with no
/skill-name in their task — the procedure below IS the instruction; it used
to live only in each rule's context_template (fellowship-dev/pylot#3447
trimmed those to facts-only, so this is now the only copy — keep it in sync
with the live rule via pylot automations get <name>, don't let it drift).
publish-cli-on-main-version-bump — fires on every push to
fellowship-dev/pylot@main (not file-filtered: GitHub's push payload commits
array caps at 20 entries, and merge-commit promotions routinely exceed that,
so a files filter risks silently missing a version bump).
0. Read the version main now carries — ref-explicit, don't rely on the
container's own checkout: git fetch origin <sha> && git show <sha>:modules/cli/package.json | jq -r .version → CLI_VERSION. Read what
npm actually serves: npm view pylot-cli version → NPM_VERSION. If they
match, nothing to publish: PUT /admin/config/cli_publish_last with
{"sha","cli_version","npm_version","build_id":null,"phase":"skipped","verdict":"unchanged","checked_at"}
and stop.
- Mismatch:
POST $PYLOT_API/admin/publish-cli(Bearer$PYLOT_DISPATCH_TOKEN) — capturebuild_id. - Poll
GET /admin/build-worker/<build_id>every 30s (max 20 min) to a terminal phase. - Re-read
npm view pylot-cli version(refresh — don't reuse step 0's). - Verdict: terminal phase not
SUCCEEDED→failed. ElseNPM_VERSION == CLI_VERSION→ok. Else →drift. - Always
PUT /admin/config/cli_publish_lastwith the full record — every branch above ends here, never exit after step 1/2 without writing it. - On
drift/failed: also comment on the repo with the log URL$PYLOT_API/admin/deploy/logs/<build_id>— nothing else readscli_publish_lastautomatically, so the comment is the only alert.
rebuild-images-on-cli-push — fires when modules/cli/src/** or its
package files push to main (narrowed from all of modules/cli/** so a
README-only push doesn't rebuild the fleet).
GET /devboxes/projects— extract everyrepofield. Empty/failed → abort, don't call batch-rebuild with an empty list.POST /admin/build-worker/batchwith{"repos": [<all repos>]}— capture thebuildsarray (repo+build_idper entry).- Poll each
GET /admin/build-worker/<build_id>every 60s (max 30 min per build) to terminal. - Log a fleet report: per repo, built+promoted / skipped / FAILED (name failures with their
build_id+ error). - Log a verification entry: total attempted / succeeded / failed with final statuses — no silent completion.
chat-worker-image-on-push — fires when gateway Lambda source
(gateway/lambda/**, gateway/shared/**, gateway/chat.mts,
gateway/gateway.mjs, harness-versions.json) pushes to main.
POST $PYLOT_API/admin/build-chat-worker(Bearer$PYLOT_DISPATCH_TOKEN) — capturebuild_id.- Poll
GET /admin/build-worker/<build_id>every 30s (max 20 min) to terminal. - Log a verification entry:
build_id+ final status — no silent completion. - On
FAILED: comment on fellowship-dev/pylot with the log URL$PYLOT_API/admin/deploy/logs/<build_id>. - No Lambda repoint here — the next
POST /admin/deploypicks up the new image.
release-gate-on-pr-to-main — fires when a PR opens/updates against
main in fellowship-dev/pylot. Runs the pre-merge corpus gate against the
computed merge commit, not the PR head, so the gate reflects what will
actually land.
0. Strip a stale label (idempotent): gh pr edit <number> --repo <repo> --remove-label release-gate-green 2>/dev/null || true.
MERGE_SHA=$(gh pr view <number> --repo <repo> --json mergeCommitSha -q .mergeCommitSha). Empty/null→ comment asking the author to resolve conflicts and push again, exit 0 (not a failure — GitHub hasn't computed a merge commit yet).git fetch origin "$MERGE_SHA" && git checkout "$MERGE_SHA" && bash scripts/ci-test-gate.sh.- Pass →
gh pr edit <number> --repo <repo> --add-label release-gate-green. Fail → comment with the failure and exit 1 (re-push retriggers).
Slack Channel Routing
Bind Slack channels to teams for message routing. Many channels can bind to the same team (many-to-one). CLI is the primary interface — no UI equivalent.
# Bind a channel to a team (additive; channel must already be known to the bot)
pylot teams channels bind <channel-id> --team <team> [--org <org>]
# List all Slack channels with their bound team (null = unbound)
pylot teams channels list [--org <org>]
# Set the org-level fallback team for unbound channels
pylot orgs set-slack-default <team> --org <org>
# Clear the org-level fallback team
pylot orgs clear-slack-default --org <org>
Default-team fallback: when a Slack message arrives on a channel with no explicit binding, it is routed to the org-level default team (if set). Unbound channels with no org default are dropped.
Async Wake Pattern
Dispatch with --context conversation_id=… → auto-wake on mission terminal + PR lifecycle.
Self-wake fallback: pylot conversations wakes-add <conv-id> in_seconds=300 content="check X"
One wake at a time; re-schedule rather than stack.
Dispatch vs. Local
| Do locally | Dispatch |
|---|---|
| Answer questions, check status, query API | Code changes, PRs, reviews |
| Read logs, explain code | Docker builds, deploys |
| Quick lookups (< 2 min) | Test suites, anything > 10 min |