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 (API Reference) 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 translation<br/>(native agent events → host shape)"]
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 / pooled)"]
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 translation — 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. | Secrets at four scopes (§4.7), credential registry (§4.2), Runtime Architecture. |
| Fail-fast secret delivery | A run that references a secret vaulted at no scope terminates immediately with a missing-secret problem naming the alias — the host re-sends with the secret attached. No mid-run pause, no partial state. | missing-secret (§4.7), Streaming Contract. |
| 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. | Repository & skill model (§5), Runtime Architecture. |
2. Authentication model
Section titled “2. Authentication model”Two bearer credentials exist. 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, secrets vaulting |
| 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 |
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 agent event stream
A-->>H: translated reply
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, upsertConversationByExternalId, getConversationByExternalId | ✔ (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) | ✔ | — |
User secrets (putUserSecrets, listUserSecrets, deleteUserSecret) | ✔ | — |
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 and granted scopes:
{ "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" ]}Call it at startup to verify configuration and discover the root tenant — with zero local state.
3. External-ID conventions
Section titled “3. External-ID conventions”Tenants, users, and conversations 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 |
| Conversation | {ns}:conversation:{host_conversation_id} | acme:conversation:ticket-4521 |
The namespace {ns} is adapter configuration — one value per host system per environment (e.g.
acme for production, acme-staging for staging). The conversation ID is whatever the host
already uses to identify the thread — a ticket number, a chat-thread ID, a call ID — derived
deterministically so the same host thread always produces the same external ID.
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 |
conversation.external_id | one tenant | per-tenant uniqueness constraint behind upsertConversationByExternalId |
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/>ONE assigned repository"]
ROL["Role rol_<br/>skill_access narrowing"]
USR["User usr_<br/>external_id, role_id (one),<br/>storage bucket, secrets"]
CON["Conversation con_<br/>external_id, context snapshot,<br/>runtime, storage bucket, secrets"]
MSG["Message msg_<br/>env, secrets (ephemeral),<br/>skill narrowing"]
REP -->|"assigned (one per tenant,<br/>inheritable from ancestors)"| TNT
TNT --> ROL
TNT --> USR
ROL -->|assigned to| USR
USR --> CON
CON --> MSG
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_idle_ttl_seconds, max_concurrent_warm) —
downstream scopes can only narrow within them. tenant.repository_id reflects the tenant’s
single assigned repository (§4.3); it is read-only on the tenant resource and managed via
PUT /tenants/{tenant_id}/repository.
| 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, the repository assignment, 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.
The credential registry is also the tenant scope of the four-scope secrets model (§4.7): a credential registered here is resolvable by every run in the tenant, always by alias, never by value.
4.3 Repository registry (rep_), skills (skl_), and the tenant assignment
Section titled “4.3 Repository registry (rep_), skills (skl_), and the tenant assignment”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 assigned to any tenant |
syncRepository | POST /repositories/{repository_id}/sync |
listRepositorySkills / createRepositorySkill | GET / POST /repositories/{repository_id}/skills |
Repositories reach tenants through a single assignment — each tenant has exactly one:
| Operation | Method & path |
|---|---|
getTenantRepository | GET /tenants/{tenant_id}/repository — the tenant’s effective assignment; when the tenant has none of its own, the nearest ancestor’s assignment is returned with inherited: true |
assignTenantRepository | PUT /tenants/{tenant_id}/repository — idempotent (201 first assignment, 200 merge/replace); body {repository_id, branch_override?}; branch_override reads a different branch of the same repository for this tenant only |
unassignTenantRepository | DELETE /tenants/{tenant_id}/repository — removes the tenant’s own assignment (it then inherits from an ancestor, or has none); 409 resource-in-use while active conversations still resolve to it with no ancestor fallback |
The assignment is the whole repository story: every role, conversation, and message in the tenant resolves to this one repository. There are no repository overrides at the role, user, conversation, or message level — what varies below the tenant is only which of its skills a run may use (§5). Child tenants inherit the nearest ancestor’s assignment, so a deployment can assign once at the integration root and serve every tenant from the same capability tree.
4.4 Roles (rol_)
Section titled “4.4 Roles (rol_)”A role is a tenant-scoped access profile with exactly one lever:
skill_access—{"mode": "all"}or{"mode": "selected", "skill_ids": [...]}, narrowing within the tenant’s assigned repository (skill IDs must belong to it, else422validation-error).
A role’s resolved grant is answered by listRoleSkills (GET /roles/{role_id}/skills): the
tenant 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. Roles carry metadata for
host/adapter bookkeeping. Also: listRoles (GET /tenants/{tenant_id}/roles, ?name= exact
filter as the recovery path), getRole / updateRole / deleteRole
(GET/PATCH/DELETE /roles/{role_id} — delete is guarded while users hold the role, unless
?force=true).
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.
Each user holds at most one role — a nullable role_id. Omitting role_id on a warm-path
refresh leaves the assignment untouched; providing it replaces it; null clears it. A user needs
a role before starting conversations (422 role-required otherwise). A user’s effective skills
are their role’s resolved grant within the tenant’s repository — answered with provenance (which
role, which repository) by listUserSkills (GET /users/{user_id}/skills); empty when the user
has no role.
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").
Users are also a secrets scope (§4.7): putUserSecrets vaults write-only alias → value
pairs available to every conversation the user owns.
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} — updateUser also changes/clears role_id |
listUserSkills | GET /users/{user_id}/skills |
putUserSecrets / listUserSecrets | PUT / GET /users/{user_id}/secrets |
deleteUserSecret | DELETE /users/{user_id}/secrets/{alias} |
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 change later. The user’s assigned role resolves the context; a user with no role is refused with422role-required. The repository is always the tenant’s (§4.3). - 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 and releases it) orwarm(the conversation keeps its sandbox alive between messages under a sliding idle timeout,idle_ttl_seconds— reset on every message, extendable or releasable viaupdateConversation). A per-conversation storage bucket is auto-attached.
Conversations are the third natural key in the chain (tenant → user → conversation): a
conversation may carry an external_id — the host’s own conversation identifier (§3.1), derived
deterministically and unique per tenant. It keys the continue-vs-create decision: if no
conversation with that external ID exists, create it; otherwise continue it. Two interaction
styles serve it:
- Deterministic upsert —
upsertConversationByExternalId(PUT /conversations/by-external-id/{external_id}):201created /200existed, the same merge semantics as tenants and users (provided → replaced, omitted → unchanged), and no409on the race — DB uniqueness is the lock. Passinitial_messageto collapse continue-vs-create and the first message into one streamed round trip. - Guarded explicit —
getConversationByExternalId(GET /conversations/by-external-id/{external_id}) to check, thencreateConversationwithexternal_idto create — which responds409external-id-conflictif the ID is taken.
listConversations also accepts ?external_id= for exact-match lookup. Conversations created
without an external ID (plain createConversation) simply carry external_id: null.
Messages are sent with createMessage (POST /conversations/{conversation_id}/messages); the
default response is a streaming application/x-ndjson body — the platform’s native agent event
stream: raw agent runtime events (deltas, complete turns with tool calls and results, the
terminal result) interleaved with platform notice lines, versioned by the
X-Shiftagent-Stream-Format response header. ?stream=false gives a blocking 201 JSON
instead. The full protocol — line vocabulary, termination and truncation rules, the reference
parsing recipe — lives in Streaming Contract. Each message carries
per-run knobs:
skill_ids— narrow this run to specific skills so the agent context stays lean (within the conversation’s effective skills; the repository is always the tenant’s).env— plaintext, non-secret run parameters. Never place secret material inenv— the runtime sees these values verbatim.secrets— write-onlyalias → valuemap, vaulted on arrival and scoped to this message’s run (ephemeral — the most specific of the four secret scopes, §4.7); 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, most specific wins). Filler output is part of the reply and the persisted message — indistinguishable from agent output by design.on_capacity—reject(default):429capacity-exhausted+Retry-Afterwhen no sandbox is available;hold: the stream first emitsqueuedplatform notices 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, any warm sandbox is released, vaulted conversation
secrets are destroyed.
4.7 Secrets at four scopes — and the missing-secret retry
Section titled “4.7 Secrets at four scopes — and the missing-secret retry”Secrets are write-only alias → value maps, vaulted at the API boundary and never returned by
any operation. The agent sees only aliases ({{secret:CRM_API_KEY}}); the egress proxy
resolves aliases to real values at the network boundary on outbound calls. A rogue agent can
exfiltrate nothing: it never holds real credentials.
Four scopes exist, resolved most specific first:
| Scope | Vaulted via | Lifetime | Typical use |
|---|---|---|---|
| Message | secrets on createMessage | This message’s run only (ephemeral) | One-shot credentials the host injects per request |
| Conversation | putConversationSecrets | Until the conversation is archived or its tenant deprovisioned | Credentials staged for one thread’s work |
| User | putUserSecrets | Persistent; available to every conversation the user owns | Per-user credentials (the user’s own CRM login, warehouse account) |
| Tenant | the credential registry (createCredential, §4.2) | Operator-managed | Shared system credentials (git tokens, service accounts) |
resolution: message → conversation → user → tenantMissing secrets fail fast. A run that references an alias vaulted at no scope terminates
immediately with a missing-secret problem naming the alias — as a terminal platform error line
on the stream, or a 422 with ?stream=false. The recovery is a one-step retry: the host
re-sends the message with the secret attached (in message.secrets, or staged first at a
longer-lived scope). There is no mid-run pause and no partial state — the failed run’s messages
are in history, and the re-sent message starts a fresh run that resumes the same session.
5. One repository per tenant, skills narrowed below it
Section titled “5. One repository per tenant, skills narrowed below it”Which repository — and therefore which skills — an agent run gets is deliberately simple:
- The repository is the tenant’s. Each tenant carries exactly one assigned repository
(
assignTenantRepository, §4.3). Child tenants without an assignment of their own inherit the nearest ancestor’s. Every role, conversation, and message in the tenant resolves to this single repository — there are no repository overrides below the tenant. - Everything below the tenant narrows skills. Roles, conversations, and messages each apply an intersection filter over the tenant repository’s skill catalog — each layer can only narrow, never widen:
effective_skills = skills of the tenant's repository (what exists) ∩ role.skill_access (what the user's role permits) ∩ conversation.selected_skill_ids (what this thread opted into) ∩ message.skill_ids (what this turn narrowed to)Worked example
Section titled “Worked example”Setup (this mirrors the spec’s examples):
- Registry: field-ops (
rep_01hzx8fieldops, 5 skills, among themdispatch-schedulerandinvoice-lookup). - Tenant Acme Field Services (
tnt_01hzx8acme001) — field-ops assigned viaPUT /tenants/tnt_01hzx8acme001/repository. - Role csr (
rol_01hzx8csr001) —skill_access: {mode: "selected", skill_ids: [dispatch-scheduler, invoice-lookup]}. - Role dispatcher —
skill_access: {mode: "all"}. - User Jane — role
csr. User Marco — roledispatcher.
| # | Scenario | Effective repository | Effective skills |
|---|---|---|---|
| 1 | Jane starts a plain conversation | field-ops (the tenant’s) | csr’s 2 selected skills |
| 2 | Marco starts a plain conversation | field-ops (the tenant’s) | all 5 field-ops skills |
| 3 | Jane’s conversation is created with selected_skill_ids: [skl_01hzx8invoice] | field-ops | the one selected skill (⊆ csr’s grant) |
| 4 | One message in Jane’s plain conversation carries skill_ids: [skl_01hzx8invoice] | field-ops | narrowed to the one listed skill for this run only; the next message falls back to scenario 1 |
| 5 | A child tenant of Acme starts its first conversation with no assignment of its own | field-ops, inherited: true | per its own roles |
Scenario 4 is the per-run focus knob: a single turn runs with a minimal skill set — leaner context, smaller action surface — without touching any stored configuration. Scenario 5 shows the inheritance: capability trees are assigned once, high in the tenant hierarchy, and flow down.
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 and
learn root_tenant_id. 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", "repository_id": null, "settings": { "filler_enabled": true, "default_agent_type": "claude-agent-sdk", "max_idle_ttl_seconds": 3600, "max_concurrent_warm": 5 }, "metadata": { "host_plan": "premium" }, "created_at": "2026-07-02T09:30:00Z", "updated_at": "2026-07-02T09:30:00Z"}Step 4 — assignTenantRepository — PUT /tenants/tnt_01hzx8acme001/repository
{ "repository_id": "rep_01hzx8fieldops" }201 — the tenant’s single repository is assigned (the response embeds the full repository, now
sync.state: "ready" with skill_count: 5):
{ "object": "tenant.repository", "tenant_id": "tnt_01hzx8acme001", "repository_id": "rep_01hzx8fieldops", "branch_override": null, "inherited": false, "repository": { "object": "repository", "id": "rep_01hzx8fieldops", "...": "..." }, "created_at": "2026-07-02T09:34:00Z", "updated_at": "2026-07-02T09:34:00Z"}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", "skill_access": { "mode": "selected", "skill_ids": ["skl_01hzx8dispatch", "skl_01hzx8invoice"] }, "metadata": {}, "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_id": "rol_01hzx8csr001"}201 — created, with the user’s single role set and 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_id": "rol_01hzx8csr001", "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", "external_id": "acme:conversation:ticket-4521", "title": "Invoice questions", "metadata": { "host_ref": "ticket-4521" }}The external_id is the third natural key (§3.1) — here derived from the host’s ticket number.
On this guarded-explicit path a taken ID responds 409 external-id-conflict; the deterministic
alternative is upsertConversationByExternalId
(PUT /conversations/by-external-id/acme%3Aconversation%3Aticket-4521 with the same body minus
external_id), which continues the existing conversation with 200 instead (§4.6).
201 — note the resolved context snapshot (Jane’s role, the tenant’s repository, the csr skill
grant) and the auto-attached conversation bucket:
{ "object": "conversation", "id": "con_01hzx8conv001", "external_id": "acme:conversation:ticket-4521", "tenant_id": "tnt_01hzx8acme001", "user_id": "usr_01hzx8jane001", "title": "Invoice questions", "status": "active", "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", "idle_ttl_seconds": null, "sandbox_state": "cold", "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 ID in the
X-Shiftagent-Conversation-Id header and a leading platform.event line.)
Step 9 — createMessage — POST /conversations/con_01hzx8conv001/messages (platform JWT)
{ "content": "Summarize today's open jobs." }200 — application/x-ndjson with X-Shiftagent-Stream-Format: claude-sdk/1: the native agent
event stream, one JSON object per line, terminated by exactly one terminal line (the agent
result event, or a platform error line):
{"object":"platform.event","type":"queued","position":2,"retry_hint_seconds":15}{"type":"system","subtype":"init","session_id":"3f7c9a12-6c1e-4b8a-9f27-c11d2e83a001","tools":["dispatch-scheduler","invoice-lookup"]}{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"You have three open jobs today."}}}{"type":"assistant","message":{"content":[{"type":"text","text":"You have three open jobs today."}]}}{"type":"result","subtype":"success","usage":{"input_tokens":1830,"output_tokens":12}}The queued line appears only under on_capacity: "hold" when the pool is saturated. 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_idreturns200— omitted fields are untouched, so the role assignment survives:{"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. The same continue-vs-create discipline applies here:
upsertConversationByExternalIdkeyed on the host’s thread ID returns200and continues the existing conversation (or201if the thread is new) — no conversation mapping to remember, just like tenants and users. ThencreateMessage,listConversations?user_id=…, 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 dedicated API surface — it composes entirely from primitives this guide already covered.
The three moving parts:
- Vaulted credentials for the external data systems, at the right scope (§4.7). Shared
system credentials go in the tenant credential registry (
createCredential→crd_), exactly like the git token in §6.1. Per-user credentials go in the user scope (putUserSecrets); thread-specific or short-lived ones ride conversation-scoped (putConversationSecrets) or per-message (message.secrets). At every scope 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 vaulted at no scope, the run fails fast with
missing-secretnaming the alias (§4.7) — the host re-sends the message with the secret attached, a one-step retry that keeps credential delivery need-to-know without any mid-run pause. - 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 agent event stream — line vocabulary, termination and truncation rules, and a client parsing recipe. |
| Adapter Implementation Guide | How to build your adapter: identity derivation, the zero-storage philosophy and cache policy, lifecycle reconciliation, stream translation duties, secrets handling, ops. |
| Runtime Architecture | The composable runtime: skills + LLM harness control, pluggable agent types, sandbox-per-run security model, warm sandboxes, 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.