---
title: "Agent Guide"
description: "How AI agents authenticate (Raft Agent Login, deploy tokens) and run releases, tickets, and shares."
category: "For agents"
order: 2
---

# Agent Guide

Hands is agent-native: everything an operator can do in the console, an AI
agent can do through the API and CLI. This guide covers how agents
authenticate and run the standard operations.

## Authentication

Two options, by scope:

| Method | Scope | Use for |
|---|---|---|
| **Raft Agent Login** | Your org-wide role (viewer/member/admin/owner) | Admin operations: creating apps, reviewing/publishing releases, triaging tickets, managing shares. |
| **Deploy token** | One app, viewer or publisher role | CI and narrow automation: publishing builds, creating shares for a single app. |

### Raft Agent Login

From a Raft-connected machine:

```bash
raft integration login --service hands-4cc7a2
raft integration invoke --service hands-4cc7a2 --list-actions
raft integration invoke --service hands-4cc7a2 --action list-apps
```

Raft stores the Agent Login session inside the integration service and applies
it when `integration invoke` calls a manifest action. It deliberately does
**not** export that session as `HANDS_AUTH_TOKEN`, and `raft integration env`
may report that the service is HTTP-actions-only. This is a security boundary,
not a missing login.

Do not expect the local `hands` CLI to inherit an integration login. Use
manifest actions for interactive admin work. Use a dedicated app deploy token
for CLI/CI automation.

Your capabilities follow your Hands org role. A `403` response names the
required role; ask an org owner to adjust membership in Org settings.

### Cross-server app access

A Hands account is scoped to the Raft server identity that first created it.
When an app admin and the target principal are on different Raft servers, the
target may not appear in the admin's same-org picker. The target should run
`whoami` and privately send the returned non-secret Hands account id. An app
admin can then grant direct access without sharing a login token:

```bash
raft integration invoke --service hands-4cc7a2 \
  --action add-app-member \
  --param app_id=<app-uuid> \
  --data-json '{"account_id":"<hands-account-uuid>","app_role":"viewer"}' \
  --json
```

Use `list-app-members` to verify the exact account id, server identity, and
app role. `update-app-member` changes the direct role and
`remove-app-member` removes only the direct grant; inherited org/server access
is unchanged.

### Deploy tokens

Created in an app's **Settings -> Deploy Tokens** UI, by the CLI when it
already has an admin bearer token, or by an admin Agent Login action:

```bash
raft integration invoke --service hands-4cc7a2 \
  --action create-deploy-token \
  --param app_id=<app-uuid> \
  --data-json '{"name":"github-ci","app_role":"publisher"}' \
  --json
```

The raw token is returned exactly once. Pipe it directly into the target CI
secret store, then discard the response file. If storage fails, revoke that
token id and mint a replacement; never leave an unused credential active.

Deploy tokens are app-scoped, never expire unless revoked, and are
attributed in audit logs as
`deploy-token:<name>@<app>`. Prefer them for CI; prefer Agent Login for
interactive agent operations.

### Integration manifest URL contract

Manifest action paths must be absolute, and URL resolution must produce the
real API route. The correct Hands contract is:

```json
{
  "execution": { "base_url": "https://hands.build" },
  "endpoint": { "path": "/api/apps" }
}
```

Do not combine `base_url: https://hands.build/api` with an action path that
also starts with `/api`; manifest validation rejects the duplicated API base.
Do not combine that nested base with `/apps` either: standard URL resolution
resets the path to `https://hands.build/apps`, which serves the dashboard
instead of the API. After changing a manifest, verify both
`--list-actions` and one real JSON action response in production.

## Standard operations

All CLI commands accept `--json` for scripting.

### Release flow (draft-first policy)

CI creates drafts; an agent reviews and publishes. **Never publish without
reviewing the changelog, and never activate without explicit, current human
authorization.**

**Raft agents — manifest actions (no CLI, no tokens).** After a one-time
`raft integration login --service <hands-service>`, release management is
plain `raft integration invoke`; the server enforces app RBAC (viewer for
reads, publisher for writes):

```bash
# create a DRAFT from an existing verified build (the server enforces draft;
# activation is impossible on this endpoint — publish-release is the only path)
raft integration invoke --service <hands-service> --action create-release \
  --param app_id=<app-uuid> --param build_id=<build-uuid>

# attach reviewed bilingual notes (change-logs/<version>/*.md is the source of truth)
raft integration invoke --service <hands-service> --action update-release \
  --param app_id=<app-uuid> --param release_id=<release-uuid> \
  --param expected_revision=<fresh-detail-revision> \
  --param release_notes='{"en":"...","zh-CN":"..."}'

# ONLY after explicit human approval of the draft:
raft integration invoke --service <hands-service> --action publish-release \
  --param app_id=<app-uuid> --param release_id=<release-uuid> \
  --param expected_revision=<same-fresh-detail-revision> \
  --param expected_scopes='[{"scope_type":"full","scope_value":"all"}]'
```

Also available: `list-releases`, `get-release`, `list-release-shares`
(viewer), `create-release-share` / `update-release-share` /
`revoke-release-share` (publisher —
share pages are how a draft gets to a human for device review; links have no
expiry unless you set one, and `update-release-share` with `expires_at: null`
restores that never-expiring state; such links die only on revoke). When
reporting a release, always cite the build's APK SHA-256 — the binary hash is
the authority, not a branch head or run id. A 403 means your identity lacks
the app role: ask an app admin to grant it; never borrow credentials.

**Humans / CI — hands CLI** (browser login or deploy token):

```bash
hands releases show <app> <releaseId>                 # inspect draft + raw changelog
hands releases update <app> <releaseId> \
  --changelog-file zh=zh.md --changelog-file en=en.md  # reviewed, per-language
hands releases publish <app> <releaseId>              # explicit go-live
```

Always derive `expected_revision` and `expected_scopes` from the same fresh
release detail immediately before a write. Every lifecycle mutation increments
the revision; a stale write returns `409 RELEASE_REVISION_CONFLICT` with zero
release, scope, fallback, or audit effects. Publish also compares the complete
scope set atomically. For a staged
full rollout with mandatory acceptance devices, keep one release and set
`scopes` to `full:all` plus each `device_group`; group members always receive
the target while other identified clients remain percentage-gated. Raise
`rollout_cohort_count` from the console or with
`PATCH /api/apps/:id/releases/:releaseId` as confidence grows. Release rows
expose `offered_count` / `current_count` so you can watch real coverage.

Each non-cancelled app/channel/product/release-type/version coordinate has one
release owner. A duplicate create returns `RELEASE_VERSION_ALREADY_EXISTS`
with that release/build/status. Cancelling disables the old lifecycle and
releases the coordinate for a corrected upload without deleting its build,
assets, or audit history. Restore reactivates the same release ID only if no
replacement owns the version. The publish CLI checks this before creating a
build so a known conflict cannot leave uploaded orphan assets. If another
publisher wins the coordinate after that preflight, the CLI marks its new build
`failed`, records the conflicting release in provenance, and reports the new
build ID for audit or manual inspection. For an Android version that reached
devices, use a higher version code so clients can update.

For a fuller per-version view, call
`GET /api/apps/:id/analytics/versions?window_days=30` with Agent Login or a
viewer-capable app token. The response includes one row per release-backed or
telemetry-only version with devices reported in the selected window, total devices seen,
update-check current/offered counts, feedback/crash counts, and artifact
downloads. `window_minutes` is also accepted for recent-report windows, but
SDK metrics pings are throttled, so this should not be labeled as true online
presence.

### TestFlight upload and distribution

TestFlight is a separate state machine from the Hands draft and from App Store
production publishing. App-level invitation copy is also separate from each
build's What to Test text. The integration exposes eight actions:

- `get-testflight-beta-app-description` (viewer): read live app-level
  `betaAppLocalizations.description` values.
- `update-testflight-beta-app-description` (publisher): upsert only supplied
  app-level locales and require exact Apple readback. Run this live metadata
  mutation only with explicit authorization.

- `upload-testflight-build` (app admin): stream the existing signed Hands IPA
  to Apple's Build Upload API.
- `get-testflight-upload-status` (viewer): poll the returned Apple upload id to
  `state.state=COMPLETE|FAILED`, retaining Apple's errors/warnings/infos.
- `list-testflight-groups` (viewer): get stable internal/external beta group
  ids for the exact app.
- `expire-testflight-build` (app admin): after explicit human authorization,
  expire one exact beta build using its ASC build id plus immutable
  version/build confirmations; a durable redacted operation receipt is written
  before Apple mutation, PATCH/readback outcomes are terminalized, and exact
  retries are no-ops linked to any prior PATCH-confirmed operation.
- `publish-testflight-build` (publisher): assign a processed build to selected
  groups, upsert localized What to Test text, and submit external Beta App
  Review when requested.
- `get-testflight-publish-status` (viewer): refresh processing, expiry, group,
  localization, review, and auto-notify state.

The `.p8` stays encrypted in Hands. No action returns it, activates a Hands
release, or creates/submits/releases an App Store production version.

```bash
# App-level Beta App Description. This does not change What to Test or a build.
raft integration invoke --service <hands-service> \
  --action update-testflight-beta-app-description \
  --param app_id=<app-uuid> \
  --data-json '{"descriptions":{"en-US":"Private collaboration for teams.","zh-Hans":"面向团队的私密协作应用。"}}' --json
```

```bash
# Upload only. Optional bundle_id is an assertion when metadata exists and a
# fallback only when build metadata is absent; a mismatch fails closed.
raft integration invoke --service <hands-service> \
  --action upload-testflight-build \
  --param app_id=<app-uuid> \
  --param build_id=<hands-build-uuid> \
  --param bundle_id=build.raft.app --json

# Poll until response.state.state is COMPLETE or FAILED.
raft integration invoke --service <hands-service> \
  --action get-testflight-upload-status \
  --param app_id=<app-uuid> \
  --param build_upload_id=<asc-build-upload-id> --json

# Once the exact ASC build is VALID, discover group ids.
raft integration invoke --service <hands-service> \
  --action list-testflight-groups \
  --param app_id=<app-uuid> \
  --param build_id=<hands-build-uuid> --json

# Internal distribution. group_ids must all be internal.
raft integration invoke --service <hands-service> \
  --action publish-testflight-build \
  --param app_id=<app-uuid> \
  --param build_id=<hands-build-uuid> \
  --data-json '{
    "distribution":"internal",
    "group_ids":["<asc-beta-group-id>"],
    "what_to_test":{"en-US":"Verify login and Activity."},
    "notify_testers":false
  }' --json

# Live-facing and irreversible for TestFlight availability: run only after an
# explicit human selects the exact build. All three confirmations are required.
raft integration invoke --service <hands-service> \
  --action expire-testflight-build \
  --param app_id=<app-uuid> \
  --param build_id=<hands-build-uuid> \
  --data-json '{
    "asc_build_id":"<exact-asc-build-id>",
    "confirm_version":"1.0.0",
    "confirm_build_number":"1000006"
  }' --json
```

External mode requires at least one existing or supplied What to Test
localization. Set `notify_testers=true` to enable Apple's automatic
notification after approval; for an already-approved build without automatic
notification pending, Hands creates the official build beta notification.
External Beta App Review can take hours,
so agents should return the submission state and recheck with
`get-testflight-publish-status` rather than holding a Raft turn open.

If the requested completion boundary includes tester notification, retain the
`publish-testflight-build` action response as the mutation receipt and require
`notification=sent` or `already_sent`. Then call the read-only
`get-testflight-publish-status` action until the exact build reports
`external_build_state=IN_BETA_TESTING`. `not_requested` and `scheduled` are not
completion states. For `scheduled`, wait for the exact testing state, replay
the same idempotent publish action to retain `already_sent`, and read status
again. Never infer notification from `VALID`, group assignment,
`BETA_APPROVED`, or `auto_notify_enabled=false`; the last field is scheduling
policy, not notification history. TestFlight notification does not activate a
Hands release or publish an App Store production version.

### Exact iOS simulator QA artifacts

Use this lane for a zipped `.app` bundle that Stamp or another agent must
download and install into Simulator by exact byte identity. It is deliberately
separate from iOS IPA/TestFlight publishing:

- the artifact kind is `ios-simulator-app` and the build is marked QA-only;
- `.ipa` and `.apk` filenames are rejected;
- Hands recomputes the uploaded ZIP's size and SHA-256 before marking it ready;
- completion is one-shot: verified bytes are copied to an immutable R2 key, so
  reusing an unexpired upload URL cannot replace the ready artifact;
- QA-only builds are rejected by the release-creation API and therefore never
  enter public latest/update/history offers.

The Agent Login flow is create → direct upload → complete → read/presign:

```bash
# 1. Declare the exact artifact. channel_id may be a UUID or slug and defaults
# to the app's default/main channel for ledger grouping only.
raft integration invoke --service <hands-service> \
  --action create-ios-simulator-artifact \
  --param app_id=<app-uuid> \
  --data-json '{
    "filename":"raft-ios-simulator.app.zip",
    "size_bytes":37563642,
    "sha256":"885b328f3a72299bd4368fd876dbcb4a8646b6f15b6e656fc8bec396a62beac8",
    "source_commit":"470023f98d154e50d7ba07b01a2cd53eb4367fc9",
    "version_name":"1.0",
    "build_number":"1",
    "bundle_id":"build.raft.app",
    "github_run_id":"29700366778",
    "github_artifact_id":"8446353537"
  }' --json > qa-artifact-create.json

# 2. PUT the bytes outside the integration transport. Use the exact method,
# URL, and Content-Type returned in the response's upload block.
curl --fail-with-body -X PUT \
  -H 'Content-Type: application/zip' \
  --upload-file raft-ios-simulator.app.zip \
  "$(jq -r .upload.url qa-artifact-create.json)"

# 3. Ask Hands to stream/hash the stored object. It becomes ready only when
# both exact byte length and SHA-256 match the declaration.
raft integration invoke --service <hands-service> \
  --action complete-ios-simulator-artifact \
  --param app_id=<app-uuid> \
  --param asset_id="$(jq -r .asset_id qa-artifact-create.json)" --json

# 4. The durable coordinate is (build_id, asset_id); download_api is a stable
# authenticated reference. Ask for a short-lived anonymous URL for binary use.
raft integration invoke --service <hands-service> \
  --action presign-ios-simulator-artifact \
  --param app_id=<app-uuid> \
  --param asset_id="$(jq -r .asset_id qa-artifact-create.json)" --json
```

Stamp should freeze both source commit and Hands asset coordinates, then
download the returned URL and verify the response's `server_sha256` before `ditto`
or `simctl install`. Use `list-ios-simulator-artifacts` with
`source_commit`, `github_run_id`, or `sha256` when recovering a coordinate;
use `get-ios-simulator-artifact` for the complete provenance record.

### Ticket triage (feedback + crashes)

```bash
hands feedback list <app> --status open --kind crash
hands feedback show <app> <ticketId>
hands feedback update <app> <ticketId> --status in_progress --assignee <name>
hands feedback comment <app> <ticketId> "reproduced; fix in progress"
hands feedback update <app> <ticketId> --status resolved
```

Crash tickets carry a grouping signature and, when the build's
`mapping.txt` was uploaded, an auto-retraced stack in the comments.
`GET /api/apps/:id/feedback/crash-groups` aggregates by signature.

Triage is a **member-level** operation: reading tickets needs `viewer`, and
updating status/assignee or adding comments needs org **member** (or an app
`publisher` — e.g. a publisher deploy token). See the
[permissions reference](permissions.md) for the full role matrix.

### Share links

```bash
hands releases share <app> <releaseId> --password <pw>   # password optional
hands releases shares <app> <releaseId>
hands releases update-share <app> <releaseId> <shareId> --ttl-seconds 1209600
hands releases revoke-share <app> <releaseId> <shareId>
```

The share URL is printed once; tokens are stored hashed.

### Apps (org member role)

```bash
# create (API; CLI covers list/get)
curl -X POST https://hands.build/api/apps \
  -H "Authorization: Bearer $HANDS_BEARER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug":"my-app","name":"My App","platform":"android"}'
```

New apps are seeded with default channels (`main`, `preview`, `nightly`) and
product types, and get a **client key** (`qk_…`) that clients must send as
`X-Hands-Client-Key` on feedback/crash submissions (the legacy
`X-Quiver-Client-Key` header is still accepted):

```bash
curl -H "Authorization: Bearer $HANDS_BEARER_TOKEN" \
  https://hands.build/api/apps/<appId>/client-key          # read
curl -X POST -H "Authorization: Bearer $HANDS_BEARER_TOKEN" \
  https://hands.build/api/apps/<appId>/rotate-client-key   # (re)generate
```

Rotation invalidates the old key immediately — client builds must be updated
with the new one.

## Handling permission (403) errors

Hands's role-403s are **machine-readable** — act on them instead of failing
silently:

```json
{
  "error": "insufficient_org_role",       // or "insufficient_app_role"
  "required_role": "member",
  "current_role": "viewer",
  "resource": "POST /api/apps",            // the action you attempted
  "org_id": "…", "app_id": null,
  "manage_url": "https://app.hands.build/orgs/{orgId}/members"
}
```

On this response:

1. **Don't retry blindly** — you are missing `required_role`, not hitting a
   transient error.
2. **Tell the human the exact next step**, quoting `manage_url`: e.g. "I need
   **admin** on this org to run `<resource>` (currently **viewer**). An org
   admin can raise my role at `<manage_url>`, then I'll retry."
3. **Retry the same request** once they confirm the role change.

App creation (`POST /api/apps`) needs an **org member or higher** specifically —
an app-member role or deploy token is not enough. New Agent Login accounts
normally start as org members; an org admin can manually downgrade an account to
viewer for read-only access. If an app should live under a different
organization, that org's member/admin creates it there rather than changing
your role in this one.

## Direct build asset upload retries

For asset ingest protocol v1, declare each asset with
`POST /api/apps/:appId/builds/:buildId/assets/uploads`, PUT the declared
bytes to `upload.url`, then POST `complete_url`. Completion synchronously
streams and verifies the full object before returning `state: ready`.
Allow a generous completion timeout for large artifacts. Verification time
depends on object size and storage/network throughput; there is no fixed
seconds-per-MB guarantee.

Keep the exact declaration body and `idempotency_key` for recovery. Repeating
it returns HTTP 200 with the same asset and its current state:

| State | `upload` | Next step |
| --- | --- | --- |
| `pending` | PUT instructions | If PUT already succeeded, retry completion with the existing bytes. Otherwise finish PUT first. |
| `verifying` | `null` | Wait and repeat the declaration to poll; do not PUT again while verification runs. |
| `ready` | `null` | Asset is complete. Repeating completion also returns HTTP 200 with `replayed: true`. |
| `failed` / `expired` | `null` | Terminal attempt; stop polling. A null upload alone never means success. |

A pending attempt past its upload expiry returns HTTP 410 instead of PUT
instructions. After a client timeout, first poll with the identical
declaration: the original completion may have finished, may still be running,
or may have lost its request lifetime. If it remains `verifying`, repeat
completion after a delay; an active verifier returns the busy response below.
An expired verifier lease can be reclaimed by completion while the upload
itself remains valid. The verifier lease lasts 15 minutes; the PUT upload
window lasts one hour. Bound retries by your job deadline.

| Completion response | Recovery |
| --- | --- |
| HTTP 503, `ASSET_UPLOAD_SEAL_INTENT_FAILED`, `retryable: true` | Honor `Retry-After` (seconds), then retry the same completion. Existing staging bytes are preserved. |
| HTTP 409, `ASSET_UPLOAD_BUSY`, `retryable: true` | Honor `Retry-After` and poll the declaration. `state: verifying` means a lease is held; it does not prove that its holder is still alive; `state: pending` means the competing verifier released its lease, so retry completion with the existing bytes. Avoid another PUT. |
| HTTP 409, `ASSET_UPLOAD_TERMINAL`, `retryable: false` | Stop retrying this attempt. |
| Other HTTP 409, including non-transient seal-intent errors | Resolve the conflict; do not retry indiscriminately. |
| HTTP 410 / 422 | Expired upload or integrity failure; stop this attempt. |

Read `GET /api/apps/{appId}/builds/{buildId}/assets/{assetId}/upload` to poll
without replaying a declaration. It returns `state`, `verification_started_at`,
`verifier_lease_expires_at`, `upload_expires_at` (Unix milliseconds),
`can_retry_complete`, and a durable `verification_error` for failed attempts.
The response is `no-store`. This endpoint requires the app publisher role.
The declaration replay and busy completion response also include these fields.

A verifier lease is not renewed: at its 15-minute deadline, call the same
completion again to take over using the existing staging bytes. A previous
verifier cannot commit after another has taken its lease. The one-hour upload
expiry still applies; no fixed verification duration per file size is promised.

`503 ASSET_UPLOAD_STORAGE_FAILED` includes `retryable:true`, `Retry-After`, and
`phase` (`staging_head`, `seal_head`, `staging_read`, `staging_stream`,
`seal_stream`, `seal_write`, or `seal_readback`). Retry the same completion
without another PUT. The attempt returns to pending and keeps its staging
bytes; a later verifier uses a new immutable seal generation. A rejected stream
operation cancels the other stream branches so they do not wait forever for a
failed consumer. Abrupt runtime termination may still leave a verifying lease
until its deadline.

Integrity failures remain terminal and include `reason` and the available
expected/actual measurements. SHA-256 mismatch includes both hashes and sizes;
size mismatch includes expected and observed size. `GET .../upload` retains
the terminal reason even after object cleanup.

For terminal attempts, create a new pending build to retry the release with
new declarations. A build already marked `failed` cannot resume uploads.
Changing only the idempotency key does not free an occupied asset slot.
The abort endpoint can discard an abandoned pending upload after verification
has stopped; failed or expired attempts may already be owned by cleanup.
Do not mark the build succeeded until every required asset is ready.

## Rules for agents

1. **Draft-first**: CI never completes a release; publishing is an explicit,
   reviewed step (see the release runbook in the repository).
2. **Changelogs are user-facing**: write them per-language
   (`{"en": …, "zh-CN": …}`); clients receive their locale's version.
3. **Attribution matters**: act under your own agent login or a token minted
   for the purpose — never reuse a human's browser session.
4. **Secrets stay out of channels**: never paste bearer tokens or deploy
   tokens into shared chat; audit logs already attribute your actions.

### Application identifiers and direct grants

Raw `/api/apps/:appId/...` requests and console `/apps/:appId/settings` links
use the full application UUID. Resolve a slug with `hands apps get <slug> --json`
and use its `id`; a name or slug is not an application ID.

Adding an application member also creates an organization viewer membership
if needed. This does not downgrade the application role: an application admin
can administer that application while remaining an organization viewer. Direct
application grants are included in the application list even when the session's
selected organization is different. Other applications in that organization are
not exposed by a direct grant.
