Integration Guide
This guide is the front door to the shiftagent Integration API — the contract between a host
system’s adapter and an on-prem shiftagent deployment. Read it once, end to end, and you will know
how the pieces fit; the companion documents then go deep on each piece (see
Where to go next). The OpenAPI specification at openapi/openapi.yaml is
normative for every operation named here — this guide references operations by operationId
and method/path, and copies its examples verbatim from the spec.
The one-sentence model:
The host system fronts all end-user traffic; a stateless, client-specific adapter derives identity from the host’s own JWT and calls the Integration API; shiftagent’s database holds all mapping state, so the adapter never needs storage of its own.
1. Architecture at a glance
Section titled “1. Architecture at a glance”Host system ── host JWT ──▶ Adapter (stateless) ── sk_int_… key ──▶ shiftagent Integration APIFour layers, one direction of trust:
flowchart LR
subgraph host["Host system"]
endUser["End user"]
hostApp["Host product UX"]
hostAuth["Host auth service<br/>(issues host JWTs)"]
endUser --> hostApp
hostAuth -.-> hostApp
end
subgraph adapter["Adapter — stateless middleweight"]
derive["deriveIdentity()<br/>host JWT → external IDs"]
orchestrate["Provisioning orchestration<br/>(cold/warm path)"]
transport["Stream + approval transport"]
end
subgraph platform["shiftagent (on-prem)"]
api["Integration API"]
db[("Platform DB<br/>all mapping state")]
subgraph runtime["Runtime"]
pool["Scheduler +<br/>sandbox pool<br/>(warm / sticky)"]
sandbox["Per-run sandboxes<br/>(zero credentials)"]
end
vault[("Vault<br/>credentials + secrets")]
egress["Egress proxy<br/>(alias → value at the<br/>network boundary)"]
git[("Git repositories<br/>skills, agent config")]
storage[("S3-style storage<br/>user + conversation buckets")]
end
external["External systems<br/>(CRM, warehouse, BI, …)"]
hostApp -- "host JWT" --> derive
derive --> orchestrate --> api
transport <--> api
api --> db
api --> pool --> sandbox
git --> sandbox
storage --> sandbox
sandbox --> egress
vault --> egress
egress --> external
The properties that make this shape work:
| Property | What it means | Where it comes from |
|---|---|---|
| Stateless adapter | The adapter holds no database and no mapping tables. Every identity question is answered by shiftagent via external_id lookups and upserts. | by-external-id subresources on tenants and users; getIntegrationSelf for zero-config bootstrap. |
| Middleweight, not thin | The adapter carries real orchestration logic — identity derivation, idempotent provisioning, stream pass-through, approval transport — it just never persists anything. | Adapter Design Spec. |
| Idempotent provisioning | Every provisioning call can be replayed safely; races collapse deterministically. Crashed cold paths heal on the next request. | PUT merge-upsert semantics + 409 name-conflict recovery (Provisioning Flow). |
| Zero-trust runtime | The agent runtime never holds real credentials. Secrets are vaulted at the API boundary; the agent sees only aliases; the egress proxy resolves them on outbound calls. | Conversation secrets + credential registry (§4), Runtime Architecture. |
| Two-party approval control | Human-in-the-loop approvals require a signed assertion minted with a per-tenant approver key — the adapter can transport an approval but can never mint one. | Approvals (§4.7), approveApproval / denyApproval. |
| Everything composed per request | Repository, skills, agent runtime, sandbox placement, filler, and capacity strategy are all resolved per conversation/message from data the API manages — nothing baked in. | Resolution cascade (§5), Runtime Architecture. |
2. Authentication model
Section titled “2. Authentication model”Two bearer credentials exist, plus one signing key that is deliberately not a bearer credential. Every operation in the spec documents which it accepts.
| Credential | Shape | Who holds it | What it is for |
|---|---|---|---|
| Integration key | sk_int_… bearer token (integrationKey scheme) | The adapter, as a service principal | Everything: provisioning, registry management, tenant-wide reads, conversations, approvals transport |
| Platform JWT | Short-lived JWT (platformJwt scheme), obtained via tokenExchange | A single end user’s request context | Conversation and message operations scoped to that one user |
| Approver key | HMAC-SHA256 or Ed25519 signing key, registered out-of-band | The host’s approval authority — never the adapter | Minting the signed assertions that resolve HITL approvals (§4.7) |
2.1 The integration key (sk_int_…)
Section titled “2.1 The integration key (sk_int_…)”The integration key is a role-mode service principal: there is no user directory behind it —
it acts as the integration itself. It is subtree-scoped: the key is bound to the integration’s
root tenant, every tenant it provisions is created as a child of that root, and the key can
never see or touch anything outside its subtree. A resource outside the subtree is
indistinguishable from a missing one (404 not-found).
All operations accept the integration key. Tenant-scope work — provisioning, registry management,
tenant-wide conversation listing (listConversations with ?tenant_id=), reconciliation sweeps —
runs under it directly.
2.2 Per-user context: token exchange, not acting-as
Section titled “2.2 Per-user context: token exchange, not acting-as”For calls made on behalf of one end user, the adapter trades the user’s external IDs for a
short-lived platform JWT via tokenExchange (POST /auth/token-exchange) and forwards the
request under that token. There is no acting-as header in this API: user context is always
carried by the token itself, which keeps the audit trail honest and the authorization model
single-pathed.
sequenceDiagram
autonumber
participant H as Host system
participant A as Adapter
participant S as Integration API
H->>A: request + host JWT
A->>A: deriveIdentity() → external tenant + user IDs
A->>S: POST /auth/token-exchange (sk_int_…)
S-->>A: platform JWT (expires_at)
A->>S: POST /conversations/{id}/messages (platform JWT)
S-->>A: NDJSON event stream
A-->>H: pass-through
Token-exchange facts worth memorizing:
- Both the tenant and the user must already exist —
tokenExchangenever provisions (404for unknown IDs). Upsert first (§6). - Suspended tenant or deactivated user →
403. This is the lazy-enforcement leg of lifecycle reconciliation: deprovisioned identities fail here even before any sweep runs. - Default TTL 15 minutes, max 60 (
ttl_seconds). The adapter may cache the JWT untilexpires_atbut must never persist it.
2.3 Which endpoints take which credential
Section titled “2.3 Which endpoints take which credential”| Surface | integrationKey | platformJwt |
|---|---|---|
| Tenants, repositories, credentials, roles, users (all provisioning + registry + directory operations) | ✔ | — |
listConversations, createConversation, getConversation, updateConversation, archiveConversation, listMessages, createMessage | ✔ (tenant-wide reach) | ✔ (scoped to the token’s user — e.g. listConversations permits only ?user_id= matching the token, else 403 insufficient-scope) |
Conversation secrets (putConversationSecrets, listConversationSecrets, deleteConversationSecret) | ✔ | — |
Approvals (listApprovals, getApproval, approveApproval, denyApproval) | ✔ (transport; resolution additionally requires the signed assertion) | — |
tokenExchange, getCapacity, getIntegrationSelf | ✔ | — |
getHealth (GET /health) | unauthenticated — the only such operation |
2.4 Zero-config bootstrap: getIntegrationSelf
Section titled “2.4 Zero-config bootstrap: getIntegrationSelf”An adapter starts with exactly one configuration value that matters: its sk_int_… key.
Everything else is discoverable. getIntegrationSelf (GET /integration/self) introspects the
key and returns its principal — root tenant, granted scopes, and the registered approver keys
(public metadata only, never secret material):
{ "object": "integration_principal", "key_id": "key_01hzx8int001", "name": "host-adapter", "root_tenant_id": "tnt_01hzx8root001", "scopes": [ "tenants:write", "users:write", "roles:write", "repositories:write", "conversations:read_all", "conversations:write" ], "approver_keys": [ { "key_id": "apk_01hzx8host001", "algorithm": "hmac-sha256", "created_at": "2026-07-01T12:00:00Z" } ]}Call it at startup to verify configuration, discover the root tenant, and learn which
signature.key_id values are valid for approval resolution — with zero local state.
3. External-ID conventions
Section titled “3. External-ID conventions”Tenants and users are addressed by the host system’s own identifiers through
by-external-id subresources. This is what makes the adapter stateless: it never maps host IDs to
shiftagent IDs in a table of its own — it just asks (or upserts) by external ID.
3.1 Namespacing
Section titled “3.1 Namespacing”External IDs MUST be namespaced by the adapter at derivation time, so multiple host systems (or multiple environments of one host system) can never collide inside one shiftagent install:
| Entity | Convention | Example |
|---|---|---|
| Tenant | {ns}:tenant:{host_tenant_id} | acme:tenant:128231 |
| User | {ns}:user:{host_user_id} | acme:user:9f27c1 |
The namespace {ns} is adapter configuration — one value per host system per environment (e.g.
acme for production, acme-staging for staging).
3.2 Canonicalization
Section titled “3.2 Canonicalization”shiftagent treats external IDs as opaque strings: compared byte-exact after trimming, max 255 characters, case-sensitive. It never normalizes. Therefore the adapter owns canonicalization and must apply it identically on every path that produces an external ID:
- Pick one casing rule for host IDs that are case-insensitive in the host system (e.g. lowercase
GUIDs) and apply it in exactly one place — the adapter’s
deriveIdentity()function. - Never derive the same host identity into two byte-different external IDs; shiftagent will faithfully create two resources.
- URL-encode reserved characters when the external ID appears in a path segment (
:→%3Awhere the client’s HTTP library requires it).
3.3 Uniqueness scopes
Section titled “3.3 Uniqueness scopes”| Field | Unique within | Enforced by |
|---|---|---|
tenant.external_id | the whole integration (the key’s subtree) | DB uniqueness constraint — the concurrency lock behind upsertTenantByExternalId |
user.external_id | one tenant | per-tenant uniqueness constraint behind upsertUserByExternalId |
Uniqueness constraints are also the race resolution mechanism: concurrent upserts of the same
external ID collapse — the winner gets 201, the loser gets 200 with the winner’s record, and
no 409 is possible on the upsert path. See Provisioning Flow for the full
race semantics.
4. The resource model tour
Section titled “4. The resource model tour”flowchart TB
subgraph registry["Integration-root registry (top-level, shared)"]
CRD["Credential crd_<br/>write-only secret"]
REP["Repository rep_<br/>pre-authenticated git registry entry"]
SKL["Skill skl_<br/>dictated by the repository"]
CRD -->|credential_id| REP
REP -->|dictates| SKL
end
TNT["Tenant tnt_<br/>external_id, settings,<br/>default_repository_id"]
ROL["Role rol_<br/>repository override +<br/>skill_access"]
USR["User usr_<br/>external_id, role_ids,<br/>storage bucket"]
CON["Conversation con_<br/>context snapshot, runtime,<br/>storage bucket, secrets"]
MSG["Message msg_<br/>env, secrets, per-message<br/>repo/skill overrides"]
APR["Approval apr_<br/>HITL gate, signed resolution"]
REP -->|attached to| TNT
TNT --> ROL
TNT --> USR
ROL -->|assigned to| USR
USR --> CON
CON --> MSG
MSG -->|may raise| APR
4.1 Tenants (tnt_)
Section titled “4.1 Tenants (tnt_)”A tenant mirrors one host-system tenant and is the unit of isolation. Its natural key is
external_id; PUT /tenants/by-external-id/{external_id} (upsertTenantByExternalId) is the
cold/warm provisioning primitive — an empty body {} is valid, so a tenant can be created with
nothing but its external ID and enriched later. tenant.settings carries runtime defaults and
caps (filler_enabled, default_agent_type, max_sticky_ttl_seconds, max_concurrent_sticky) —
downstream scopes can only narrow within them. default_repository_id is the fall-through of the
resolution cascade (§5).
| Operation | Method & path |
|---|---|
upsertTenantByExternalId | PUT /tenants/by-external-id/{external_id} |
getTenantByExternalId | GET /tenants/by-external-id/{external_id} |
deleteTenantByExternalId | DELETE /tenants/by-external-id/{external_id} — the reconciliation-path deprovision: cascades over conversations, users, attachments, and vaulted secrets in one call |
listTenants / createTenant | GET / POST /tenants |
getTenant / updateTenant / deleteTenant | GET / PATCH / DELETE /tenants/{tenant_id} |
4.2 Credential registry (crd_)
Section titled “4.2 Credential registry (crd_)”A top-level, vault-style registry of external-system credentials — git access tokens today,
warehouse/BI credentials later (§7). The secret field of createCredential
(POST /credentials) is write-only: vaulted on arrival, never returned by any operation.
Everything else references credentials by crd_ ID only. Deletion (deleteCredential,
DELETE /credentials/{credential_id}) is guarded: 409 resource-in-use while anything
references the credential — to rotate, register the new one, repoint, then delete the old one.
listCredentials (GET /credentials) lists handles — names, types, metadata — never material.
4.3 Repository registry (rep_) and skills (skl_)
Section titled “4.3 Repository registry (rep_) and skills (skl_)”Repositories are top-level, pre-authenticated registry entries — scoped to the integration
root, not tenant-owned. Registering one (registerRepository, POST /repositories) supplies
name (registry-unique), repo_url, branch, provider, and a credential_id — after which
nobody downstream ever handles git credentials again. Registration starts an asynchronous sync
(sync.state: pending → syncing → ready | error) that scans the repository for skills.
The repository dictates which skills exist. Skills are read from the repository
(listRepositorySkills, GET /repositories/{repository_id}/skills — cached by default,
?refresh=true for a fresh git read) or explicitly added (createRepositorySkill,
POST /repositories/{repository_id}/skills, source: "manual"). syncRepository
(POST /repositories/{repository_id}/sync) triggers a re-scan.
| Operation | Method & path |
|---|---|
registerRepository / listRepositories | POST / GET /repositories |
getRepository / updateRepository / deleteRepository | GET / PATCH / DELETE /repositories/{repository_id} — delete is guarded: 409 resource-in-use while attached or pinned anywhere |
syncRepository | POST /repositories/{repository_id}/sync |
listRepositorySkills / createRepositorySkill | GET / POST /repositories/{repository_id}/skills |
Repositories reach tenants through attachments:
| Operation | Method & path |
|---|---|
listTenantRepositories | GET /tenants/{tenant_id}/repositories |
attachTenantRepository | PUT /tenants/{tenant_id}/repositories/{repository_id} — idempotent; {"is_default": true} also sets tenant.default_repository_id; branch_override reads a different branch for this tenant only |
detachTenantRepository | DELETE /tenants/{tenant_id}/repositories/{repository_id} — 409 resource-in-use while it is the default and dependents resolve to it |
4.4 Roles (rol_)
Section titled “4.4 Roles (rol_)”A role is a tenant-scoped access profile with exactly two levers:
repository_id— an optional repository override;nullmeans the role resolves to the tenant’s default repository.skill_access—{"mode": "all"}or{"mode": "selected", "skill_ids": [...]}, narrowing within the role’s effective repository.
A role’s resolved grant is answered by listRoleSkills (GET /roles/{role_id}/skills):
effective repository’s skills ∩ skill_access. Role name is unique per tenant, which is
load-bearing for replay-safe provisioning: a crashed cold path that retries createRole
(POST /tenants/{tenant_id}/roles) gets a deterministic 409 name-conflict carrying
conflicting_resource_id, fetches that role, and continues. Also: listRoles
(GET /tenants/{tenant_id}/roles, ?name= exact filter as the recovery path), getRole /
updateRole / deleteRole (GET/PATCH/DELETE /roles/{role_id}).
4.5 Users (usr_)
Section titled “4.5 Users (usr_)”A user belongs to one tenant and is provisioned by external ID exactly like tenants:
upsertUserByExternalId (PUT /tenants/{tenant_id}/users/by-external-id/{external_id}), empty
body valid, merge semantics provided → replaced, omitted → unchanged, null → cleared — so a
warm-path refresh that omits role_ids never wipes role assignments.
On creation every user is automatically attached to an S3-style storage bucket
(storage: {provider: "platform", bucket_uri: "s3://…"}); a host-owned bucket can be linked later
via updateUser (storage.provider: "external"). A user’s effective skills are the union over
its roles of each role’s resolved grant — answered with provenance (which role, which
repository) by listUserSkills (GET /users/{user_id}/skills).
Deactivation is soft and deliberate: deactivateUser (DELETE /users/{user_id}) suspends the
user — token exchange fails 403, new conversations are rejected, data is retained — and
deactivated ≠ absent: upserts never resurrect a suspended user; only updateUser with
status: "active" reactivates. This is what makes the reconciliation sweep safe.
| Operation | Method & path |
|---|---|
upsertUserByExternalId / getUserByExternalId | PUT / GET /tenants/{tenant_id}/users/by-external-id/{external_id} |
listTenantUsers | GET /tenants/{tenant_id}/users |
listUsers | GET /users — cross-tenant over the key’s subtree (?tenant_id=, ?email=, ?status=) |
getUser / updateUser / deactivateUser | GET / PATCH / DELETE /users/{user_id} |
listUserRoles | GET /users/{user_id}/roles |
assignUserRole / unassignUserRole | PUT / DELETE /users/{user_id}/roles/{role_id} — both idempotent |
listUserSkills | GET /users/{user_id}/skills |
4.6 Conversations (con_) and messages (msg_)
Section titled “4.6 Conversations (con_) and messages (msg_)”A conversation is created for a user (createConversation, POST /conversations) and does two
important things at birth:
- Snapshots its context — user → role → repository → skills, recorded in
context {role_id, repository_id, skill_ids}so history is self-explaining even after roles or repositories change later. If the user holds multiple roles and norole_idis given, the API refuses with422role-required— no silent guessing. - Fixes its runtime placement —
runtime.agent_type(open enum:claude-agent-sdk,codex,deepagent, …; defaults from tenant settings) andruntime.mode:pooled(default — each message claims a warm-pool sandbox) orsticky(a dedicated sandbox leased forsticky_ttl_seconds, refreshed per message, extendable or releasable viaupdateConversation). A per-conversation storage bucket is auto-attached.
Messages are sent with createMessage (POST /conversations/{conversation_id}/messages); the
default response is a streaming application/x-ndjson body — one ConversationEvent per line,
monotonic seq, exactly one terminal event (message_end or error); ?stream=false gives a
blocking 201 JSON instead. The full event protocol lives in
Streaming Contract. Each message carries per-run knobs — most specific
wins in every cascade:
repository_id— one-shot repository override (§5).skill_ids— narrow this run to specific skills so the agent context stays lean.env— plaintext, non-secret run parameters. Never place secret material inenv— the runtime sees these values verbatim.secrets— write-onlyalias → valuemap, vaulted on arrival, conversation-scoped; the agent sees only{{secret:ALIAS}}placeholders, resolved by the egress proxy at the network boundary.filler {enabled}— override the filler cascade (tenant setting → conversation → message); filler deltas arrive flaggeddata.filler: trueso hosts can render or suppress them.on_capacity—reject(default):429capacity-exhausted+Retry-Afterwhen no sandbox is available;hold: the stream first emitsqueuedevents until a sandbox frees, bounded by the deployment’s max hold time. Pre-check pool state withgetCapacity(GET /capacity).
Conversation-scoped secrets also have a standalone surface, independent of any message:
putConversationSecrets (PUT /conversations/{conversation_id}/secrets),
listConversationSecrets (GET, aliases and timestamps only — never values), and
deleteConversationSecret (DELETE /conversations/{conversation_id}/secrets/{alias}).
Listing is service-principal-friendly: listConversations (GET /conversations) requires
exactly one of ?user_id= or ?tenant_id= — one user’s conversations, or the whole tenant’s.
History is listMessages (GET /conversations/{conversation_id}/messages). Archiving is soft
(archiveConversation, DELETE /conversations/{conversation_id}): history stays readable,
writes get 409 conversation-archived, sticky sandboxes are released, vaulted secrets are
destroyed.
4.7 Approvals (apr_)
Section titled “4.7 Approvals (apr_)”Mid-run, the agent may raise a human-in-the-loop gate: the stream emits approval_required
carrying the Approval object — reason (the agent’s stated need) and requested_items[]
({kind: "action" | "secret", description, alias?}) — and the message parks in
awaiting_approval. Hosts that poll instead of streaming use listApprovals
(GET /approvals, e.g. ?status=pending&tenant_id=… as an inbox) and getApproval
(GET /approvals/{approval_id}).
Resolution — approveApproval (POST /approvals/{approval_id}/approve) / denyApproval
(POST /approvals/{approval_id}/deny) — requires a signed assertion: HMAC-SHA256 or
Ed25519 over the canonical JSON {"approval_id":"…","decision":"approve|deny","exp":…}, minted
with a per-tenant approver key registered out-of-band. The approver key is cryptographically
distinct from the sk_int_ service key: the adapter transports approvals but can never mint
one, and neither can a rogue agent — two-party control. The approve body may additionally supply
requested secrets (alias → value, vaulted exactly like putConversationSecrets) — this is the
approval⇄vault weave: the agent asks for an alias, the approver supplies the value, the run
resumes seeing only the alias.
5. The repository resolution cascade
Section titled “5. The repository resolution cascade”Which repository — and therefore which skills — an agent run gets is resolved through a five-level cascade, most specific wins:
message.repository_id → conversation.repository_id → user.default_repository_id → role.repository_id → tenant.default_repository_idEffective skills at message time are then the intersection of every narrowing that is set:
effective repo's skills ∩ role.skill_access ∩ conversation.selected_skill_ids ∩ message.skill_idsWorked example
Section titled “Worked example”Setup (this mirrors the spec’s examples and the mock-server seed):
- Registry: field-ops (
rep_01hzx8fieldops, 5 skills, among themdispatch-schedulerandinvoice-lookup) and dispatch-tools (rep_01hzx8dispatchtools, 2 skills). - Tenant Acme Field Services (
tnt_01hzx8acme001) — field-ops attached withis_default: true. - Role csr (
rol_01hzx8csr001) —repository_id: null,skill_access: {mode: "selected", skill_ids: [dispatch-scheduler, invoice-lookup]}. - Role dispatcher —
repository_id: rep_01hzx8dispatchtools(override),skill_access: {mode: "all"}. - User Jane (
usr_01hzx8jane001) — rolecsr,default_repository_id: null. - User Marco — role
dispatcher.
| # | Scenario | Level that wins | Effective repository | Effective skills |
|---|---|---|---|---|
| 1 | Jane starts a plain conversation | tenant default | field-ops | csr’s 2 selected skills |
| 2 | Marco starts a plain conversation | role override | dispatch-tools | all 2 dispatch-tools skills |
| 3 | Jane’s user record gets default_repository_id: rep_01hzx8dispatchtools via updateUser | user override (outranks the role) | dispatch-tools | dispatch-tools skills ∩ csr’s skill_access |
| 4 | Jane starts a conversation with repository_id: rep_01hzx8dispatchtools in createConversation | conversation override | dispatch-tools | snapshotted into context at creation |
| 5 | One message in Jane’s plain conversation carries repository_id: rep_01hzx8dispatchtools and skill_ids: [skl_01hzx8invoice] in createMessage | message override — this run only | dispatch-tools | narrowed to the one listed skill; the next message falls back to scenario 1 |
Scenario 5 is the “magic thing for one command”: a single run borrows a different toolset without disturbing the conversation’s snapshot. Scenario 3 vs 2 shows why the order matters — a per-user pin is more specific than a role-wide default.
The same most-specific-wins philosophy governs the filler cascade
(tenant.settings.filler_enabled → conversation.filler → message.filler) and capacity
strategy (on_capacity per conversation-create and per message).
6. Quickstart
Section titled “6. Quickstart”The complete call sequence, with request/response JSON copied from the spec. Two distinct phases: one-time setup (runs once per integration, typically by an operator or the adapter’s bootstrap) and the cold path (runs per host tenant, on first contact). The warm path — every subsequent request — is the same code path with the cold-path steps skipped by status code.
All requests carry Authorization: Bearer sk_int_… unless a platform JWT is called out.
6.0 Bootstrap — introspect the key
Section titled “6.0 Bootstrap — introspect the key”getIntegrationSelf — GET /integration/self → the principal shown in §2.4. Verify scopes,
learn root_tenant_id and the registered approver_keys. No other configuration is needed.
6.1 One-time setup — credential, then repository
Section titled “6.1 One-time setup — credential, then repository”Step 1 — createCredential — POST /credentials
{ "name": "git-main-token", "type": "git_pat", "secret": "example-token-value-never-echoed", "metadata": { "rotation": "quarterly" }}201 — the secret is vaulted and not echoed; only the handle comes back:
{ "object": "credential", "id": "crd_01hzx8gitmain", "name": "git-main-token", "type": "git_pat", "metadata": { "rotation": "quarterly" }, "created_at": "2026-07-02T09:30:30Z", "updated_at": "2026-07-02T09:30:30Z"}Step 2 — registerRepository — POST /repositories
{ "name": "field-ops", "repo_url": "https://git.example.com/agent-skills/field-ops.git", "branch": "main", "provider": "generic", "credential_id": "crd_01hzx8gitmain"}201 — registered, pre-authenticated, async skill scan started:
{ "object": "repository", "id": "rep_01hzx8fieldops", "name": "field-ops", "repo_url": "https://git.example.com/agent-skills/field-ops.git", "branch": "main", "provider": "generic", "credential_id": "crd_01hzx8gitmain", "sync": { "state": "syncing", "last_synced_at": null, "error": null }, "skill_count": 0, "metadata": {}, "created_at": "2026-07-02T09:31:00Z", "updated_at": "2026-07-02T09:31:00Z"}Poll getRepository (GET /repositories/{repository_id}) until sync.state: "ready", then
inspect the catalog with listRepositorySkills (GET /repositories/{repository_id}/skills):
{ "object": "list", "data": [ { "object": "skill", "id": "skl_01hzx8dispatch", "repository_id": "rep_01hzx8fieldops", "name": "dispatch-scheduler", "description": "Plan and assign field-technician dispatch schedules.", "version": "1.2.0", "path": ".claude/skills/dispatch-scheduler/SKILL.md", "source": "discovered", "metadata": {}, "created_at": "2026-07-02T09:32:00Z", "updated_at": "2026-07-02T09:32:00Z" }, { "object": "skill", "id": "skl_01hzx8invoice", "repository_id": "rep_01hzx8fieldops", "name": "invoice-lookup", "description": "Retrieve and explain customer invoices.", "version": "1.0.3", "path": ".claude/skills/invoice-lookup/SKILL.md", "source": "discovered", "metadata": {}, "created_at": "2026-07-02T09:32:00Z", "updated_at": "2026-07-02T09:32:00Z" } ], "has_more": false, "next_cursor": null}6.2 Cold path — first contact from a host tenant
Section titled “6.2 Cold path — first contact from a host tenant”Step 3 — upsertTenantByExternalId — PUT /tenants/by-external-id/acme%3Atenant%3A128231
{ "name": "Acme Field Services", "metadata": { "host_plan": "premium" }}201 — created; the status code is the branch signal. ({} would also have been a valid
body — external-ID-only creation always succeeds.)
{ "object": "tenant", "id": "tnt_01hzx8acme001", "external_id": "acme:tenant:128231", "name": "Acme Field Services", "status": "active", "default_repository_id": null, "settings": { "filler_enabled": true, "default_agent_type": "claude-agent-sdk", "max_sticky_ttl_seconds": 3600, "max_concurrent_sticky": 5 }, "metadata": { "host_plan": "premium" }, "created_at": "2026-07-02T09:30:00Z", "updated_at": "2026-07-02T09:30:00Z"}Step 4 — attachTenantRepository — PUT /tenants/tnt_01hzx8acme001/repositories/rep_01hzx8fieldops
{ "is_default": true }201 — attached and set as tenant.default_repository_id (the attachment embeds the full
repository, now sync.state: "ready" with skill_count: 5).
Step 5 — createRole — POST /tenants/tnt_01hzx8acme001/roles
{ "name": "csr", "description": "Customer service representative", "skill_access": { "mode": "selected", "skill_ids": ["skl_01hzx8dispatch", "skl_01hzx8invoice"] }}201:
{ "object": "role", "id": "rol_01hzx8csr001", "tenant_id": "tnt_01hzx8acme001", "name": "csr", "description": "Customer service representative", "repository_id": null, "skill_access": { "mode": "selected", "skill_ids": ["skl_01hzx8dispatch", "skl_01hzx8invoice"] }, "created_at": "2026-07-02T09:35:00Z", "updated_at": "2026-07-02T09:35:00Z"}If a previous cold path crashed after creating this role, the retry gets 409 name-conflict
with conflicting_resource_id — fetch it with getRole and continue. That recovery rule is what
makes the whole sequence replay-safe.
Step 6 — upsertUserByExternalId — PUT /tenants/tnt_01hzx8acme001/users/by-external-id/acme%3Auser%3A9f27c1
{ "email": "jane.doe@acme.example.com", "display_name": "Jane Doe", "role_ids": ["rol_01hzx8csr001"]}201 — created, with the platform storage bucket auto-attached:
{ "object": "user", "id": "usr_01hzx8jane001", "tenant_id": "tnt_01hzx8acme001", "external_id": "acme:user:9f27c1", "email": "jane.doe@acme.example.com", "display_name": "Jane Doe", "status": "active", "role_ids": ["rol_01hzx8csr001"], "default_repository_id": null, "storage": { "provider": "platform", "bucket_uri": "s3://shiftagent-tenant-acme/usr_01hzx8jane001" }, "metadata": {}, "created_at": "2026-07-02T09:36:00Z", "updated_at": "2026-07-02T09:36:00Z"}Step 7 — tokenExchange — POST /auth/token-exchange
{ "tenant_external_id": "acme:tenant:128231", "user_external_id": "acme:user:9f27c1", "ttl_seconds": 900}200:
{ "object": "token", "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c3JfMDFoeng4amFuZTAwMSJ9.example", "token_type": "Bearer", "expires_at": "2026-07-02T10:15:00Z", "tenant_id": "tnt_01hzx8acme001", "user_id": "usr_01hzx8jane001"}Step 8 — createConversation — POST /conversations (under the platform JWT)
{ "user_id": "usr_01hzx8jane001", "title": "Invoice questions", "metadata": { "host_ref": "ticket-4521" }}201 — note the resolved context snapshot (Jane’s single role, the tenant-default repository,
the csr skill grant) and the auto-attached conversation bucket:
{ "object": "conversation", "id": "con_01hzx8conv001", "tenant_id": "tnt_01hzx8acme001", "user_id": "usr_01hzx8jane001", "title": "Invoice questions", "status": "active", "repository_id": null, "context": { "role_id": "rol_01hzx8csr001", "repository_id": "rep_01hzx8fieldops", "skill_ids": ["skl_01hzx8dispatch", "skl_01hzx8invoice"] }, "selected_skill_ids": null, "runtime": { "agent_type": "claude-agent-sdk", "mode": "pooled", "sticky_ttl_seconds": null, "sandbox_state": "warm", "expires_at": null }, "filler": null, "storage": { "provider": "platform", "bucket_uri": "s3://shiftagent-tenant-acme/con_01hzx8conv001" }, "message_count": 0, "last_message_at": null, "metadata": { "host_ref": "ticket-4521" }, "created_at": "2026-07-02T10:00:00Z", "updated_at": "2026-07-02T10:00:00Z"}(To collapse steps 8 and 9 into one round trip, pass initial_message to createConversation —
the response is then the NDJSON stream, with the conversation object riding in
message_start.data.conversation.)
Step 9 — createMessage — POST /conversations/con_01hzx8conv001/messages (platform JWT)
{ "content": "Summarize today's open jobs." }200 — application/x-ndjson, one event per line, monotonic seq, terminated by exactly one
message_end (or error):
{"object":"conversation.event","type":"queued","conversation_id":"con_01hzx8conv001","message_id":null,"seq":0,"data":{"position":2,"retry_hint_seconds":15},"created_at":"2026-07-02T10:00:00Z"}{"object":"conversation.event","type":"message_start","conversation_id":"con_01hzx8conv001","message_id":"msg_01hzx8asst001","seq":1,"data":{"role":"assistant"},"created_at":"2026-07-02T10:00:01Z"}{"object":"conversation.event","type":"content_delta","conversation_id":"con_01hzx8conv001","message_id":"msg_01hzx8asst001","seq":2,"data":{"text":"One moment while I pull that up — ","filler":true},"created_at":"2026-07-02T10:00:01Z"}{"object":"conversation.event","type":"content_delta","conversation_id":"con_01hzx8conv001","message_id":"msg_01hzx8asst001","seq":3,"data":{"text":"You have three open jobs today."},"created_at":"2026-07-02T10:00:03Z"}{"object":"conversation.event","type":"message_end","conversation_id":"con_01hzx8conv001","message_id":"msg_01hzx8asst001","seq":4,"data":{"message":{"object":"message","id":"msg_01hzx8asst001","...":"..."}},"created_at":"2026-07-02T10:00:04Z"}The queued line appears only under on_capacity: "hold" when the pool is saturated; the
"filler": true delta appears only when the filler cascade resolves to enabled — render it or
suppress it, it is not part of the persisted message. Both the user message and the completed
assistant reply land in history (listMessages) regardless of what happens to the connection.
Full protocol: Streaming Contract.
6.3 Warm path — every request after the first
Section titled “6.3 Warm path — every request after the first”Same code path; the branch happens on status codes, so there is nothing to remember between requests:
-
upsertTenantByExternalIdreturns200(existed; provided fields merged — e.g. a name refresh from the host JWT) → skip steps 4–5 entirely. -
upsertUserByExternalIdwith the profile fields but withoutrole_idsreturns200— omitted fields are untouched, so role assignments survive:{"email": "jane.doe@acme.example.com","display_name": "Jane Doe"} -
tokenExchange→ fresh platform JWT (or a cached one still short ofexpires_at). -
Straight to conversations:
listConversations?user_id=…,createMessage, etc.
The warm path costs two upserts and a token exchange — all idempotent, all safe to race. The full walkthrough (including the sequence diagrams, the crash-recovery matrix, and the double-provision race) is in Provisioning Flow.
7. The context-gathering pattern
Section titled “7. The context-gathering pattern”A recurring integration need goes beyond skills-as-tools: before doing any real work, the agent should know things about the entity it is working for — the account’s history, open items, key figures — data that lives in the host’s external data systems (a warehouse such as Snowflake, a BI layer such as ThoughtSpot, an operational Postgres, a CRM). This section documents the recommended pattern. It is a pattern, not new API surface — it composes entirely from primitives this guide already covered.
The three moving parts:
- Vaulted credentials for the external data systems. Register each system’s credential once
in the credential registry (
createCredential→crd_), exactly like the git token in §6.1 — or supply them conversation-scoped at run time viaputConversationSecrets/message.secrets. Either way the zero-trust invariant holds: the agent only ever sees aliases; the egress proxy resolves them at the network boundary. - A custom “context skill” — e.g.
owner-context— added to the skills repository (or registered explicitly withcreateRepositorySkill,POST /repositories/{repository_id}/skills). Its script queries the external systems on demand, using aliased credentials, and emits a structured context brief. - The agent loop runs it first. On a fresh conversation the agent executes the context skill
before other work, so every subsequent step operates on gathered context. Because assistant
turns persist to history (
listMessages), conversation history carries the context forward — later turns in the same conversation do not re-gather unless staleness demands it.
Wired into the message flow:
- The adapter (or host) can nudge deterministically — narrow the first message to the context
skill with
message.skill_ids: ["skl_…owner_context"], pass run parameters inenv(e.g.{"ACCOUNT_REF": "…"}), and supply short-lived connection secrets inmessage.secrets. - If a required credential is missing, the agent raises a
secret-kind approval (requested_items: [{"kind": "secret", "alias": "WAREHOUSE_PASSWORD", …}]) and the approver supplies it in the signedapproveApprovalbody — the approval⇄vault weave from §4.7. - Re-gather timing — when the agent decides context is stale, what triggers a re-gather mid-conversation — lives inside the skill’s own instructions. Encode your refresh policy there (for example, “re-run this skill when the conversation references data older than the current shift”); the skill is versioned in your repository like any other capability.
8. Where to go next
Section titled “8. Where to go next”| Document | What it covers |
|---|---|
| Provisioning Flow | The cold/warm walkthrough in full: sequence diagrams, merge-upsert semantics, race and crash-recovery behavior, reconciliation sweeps. |
| Streaming Contract | The NDJSON event protocol — every event type, ordering rules, truncation detection, and a client parsing recipe. |
| Adapter Implementation Guide | How to build your adapter: identity derivation, the zero-storage philosophy and cache policy, lifecycle reconciliation, HITL transport duties, ops. |
| Runtime Architecture | The composable runtime: skills + LLM harness control, pluggable agent types, sandbox-per-run security model, filler and capacity mechanics. |
| API Reference | Generated from the platform’s OpenAPI 3.1 specification — every operation, schema, and example. |
The OpenAPI specification remains the single source of truth. When this guide and the spec disagree, the spec wins; file the discrepancy against the guide.