shiftagent Integration API
Overview
Host-system embedding surface for on-prem shiftagent — tenant/user provisioning by external ID, repository & skill registry, roles, conversations with streamed agent replies, and scoped write-only secret vaulting.
shiftagent Integration API 0.1.0
Section titled “shiftagent Integration API 0.1.0”The shiftagent Integration API is the contract between a host system’s adapter and an on-prem shiftagent deployment. The host system fronts all end-user traffic; a stateless, client-specific adapter derives identity from the host’s own JWT and calls this API. shiftagent’s database holds all mapping state — the adapter never needs storage of its own.
Host system ── host JWT ──▶ Adapter (stateless) ── sk_int_… key ──▶ shiftagent Integration API
Conventions
- JSON fields, query parameters, and enum values are
snake_case; URL segments arekebab-case. - Every resource carries an
objecttype discriminator and a prefixed ID (tnt_,usr_,rol_,rep_,skl_,crd_,con_,msg_). - Lists are cursor-paginated:
{ "object": "list", "data": [...], "has_more": true|false, "next_cursor": "..." }withlimit,starting_after, andending_beforequery parameters. - Errors are RFC 9457
application/problem+json(see the problem type registry below). - Timestamps are ISO 8601 / RFC 3339, always UTC.
Authentication
Two bearer credentials exist; every operation documents which it accepts.
| Scheme | Token | Who holds it | Used for |
|---|---|---|---|
integrationKey | sk_int_… service key | The adapter (service principal) | Everything: provisioning, registry management, tenant-wide reads, conversations |
platformJwt | Short-lived JWT from tokenExchange | A single end user’s request context | Conversation and message operations scoped to that user |
The integration key is role-mode (no user directory behind it) and is
scoped to the integration’s root tenant — every tenant it provisions is
a child of that root, and the key can never see outside its subtree. Use
getIntegrationSelf (GET /integration/self) to introspect the key.
For per-user calls the adapter exchanges external IDs for a short-lived
platform JWT via tokenExchange (POST /auth/token-exchange) and
forwards requests under that token. There is no acting-as header: user
context is always carried by the token, tenant-scope calls run under the
service key directly.
getHealth (GET /health) is the only unauthenticated operation.
External ID namespacing
Tenants, users, and conversations are addressed by the host system’s
identifiers via by-external-id subresources. External IDs are opaque
to shiftagent: compared byte-exact after trimming, max 255 characters,
case-sensitive. Adapters MUST namespace them at derivation so multiple
host systems (or environments) never collide:
- tenants —
{ns}:tenant:{host_tenant_id}(e.g.acme:tenant:128231) - users —
{ns}:user:{host_user_id}(e.g.acme:user:9f27c1) - conversations —
{ns}:conversation:{host_conversation_id}(e.g.acme:conversation:ticket-4521) — derived deterministically from the host’s own conversation/thread identifier; this is what keys the continue-vs-create decision
The namespace {ns} is adapter configuration. The adapter owns
canonicalization (e.g. lowercasing GUIDs) — shiftagent never normalizes.
Upsert semantics (PUT …/by-external-id/{external_id})
The provisioning primitive is an idempotent merge-upsert:
201— the resource did not exist and was created. An empty body{}is valid: external ID–only creation is guaranteed to succeed, all other fields are enrichable later.200— the resource existed; provided fields were merged.- Per-field merge rules: provided → replaced; omitted → unchanged;
explicit
null→ cleared (nullable fields only). Adapters that omitrole_idon the warm path therefore never touch the role assignment. - Concurrent upserts of the same external ID are resolved by a uniqueness
constraint: the winner gets
201, the loser gets200with the winner’s record. Neither errors — there is no 409 on this path.
409 conflicts with a conflicting_resource_id extension apply only to
named sub-resources (repositories, roles, skills, credentials), where
they make crashed provisioning runs recoverable: fetch the conflicting
resource by ID and continue.
Idempotency
All POST operations accept an optional Idempotency-Key header.
Responses are cached for 24 hours per (key principal, operation,
idempotency key); a replay returns the original status and body with
Idempotency-Replayed: true. The same key with a different request payload
yields 409 idempotency-key-conflict. PUT upserts and DELETEs are
idempotent by construction and do not need the header.
Streaming (NDJSON)
createMessage (and createConversation /
upsertConversationByExternalId with initial_message) responds with
application/x-ndjson: one JSON object per line. The stream is the
platform’s native agent event stream — raw Claude Agent SDK events
passed through verbatim — plus a small set of platform notice lines.
Adapters translate this stream into whatever shape their host system
needs; the platform does not re-encode it.
- Agent events (the majority of lines): raw runtime events with a
top-level
typefield —system(run init, carries the session id),stream_event(incremental deltas, includingtext_deltachunks),assistant/user(complete turn payloads incl. tool calls and tool results), andresult(the terminal event, carrying the run outcome and usage). The event vocabulary is versioned by theX-Shiftagent-Stream-Formatresponse header (claude-sdk/1); new event types are additive — ignore unknown lines. - Platform notices:
{"object":"platform.event","type":"queued",...}capacity-hold lines (see below) and{"event":"ping"}keepalives. - Termination: every complete stream ends with exactly one terminal
line — the agent
resultevent (success or failure) or a{"object":"platform.event","type":"error"}line carrying an RFC 9457 problem object. A stream that closes without a terminal line MUST be treated as truncated; reconcile vialistMessages(the run continues server-side and the assistant message lands in history regardless). ?stream=falseoncreateMessageblocks until the run completes and returns the persisted assistantMessageas JSON instead.
Runtime, warm sandboxes & capacity
Each conversation runs on a sandboxed agent runtime, selected by
runtime.agent_type (open enum — e.g. claude-agent-sdk, codex,
deepagent; defaults from tenant settings). Two placement modes:
pooled(default) — each message claims a warm-pool sandbox and releases it when the run ends.warm— the conversation keeps its sandbox alive between messages under a sliding idle timeout (idle_ttl_seconds, default 300): every message resets the timer; when it lapses the sandbox is recycled back to the pool. Warmth is a latency optimization, never state — after expiry the next message cold-starts a fresh sandbox and resumes the same session seamlessly. The most recent message’s runtime settings govern what happens next; an existing warm sandbox is always used when present (even if the latest request sayspooled— it simply stops being kept warm afterward). Tenant settings cap the max idle TTL and max concurrent warm sandboxes.
When the pool is exhausted, the caller-chosen on_capacity strategy
applies: reject (default) → 429 capacity-exhausted with
Retry-After; hold → the request is held and the stream first emits
queued platform notices (position, retry_hint_seconds) until a
sandbox frees, bounded by a deployment-configured maximum hold time (then
capacity-exhausted). getCapacity exposes live pool state for
pre-checks.
Env vars & secrets (four scopes)
env— plaintext, non-secret run parameters. Never put secret material inenv— values are visible to the agent runtime verbatim.secrets— write-onlyalias → valuemaps, vaulted at the API boundary and never returned by any operation. The agent sees only aliases (e.g.{{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.- Secrets exist at four scopes, resolved most-specific-first
(
message → conversation → user → tenant):- message — the
secretsmap oncreateMessage; ephemeral, lives for that run only. - conversation —
putConversationSecrets; destroyed when the conversation is archived or its tenant deprovisioned. - user —
putUserSecrets; persists across the user’s conversations. - tenant — the operator-managed credential registry
(
/credentials).
- message — the
- A run that references an alias vaulted at no scope fails fast: the
stream terminates with a
missing-secretproblem naming the alias (?stream=false→422). The host re-sends the message with the secret attached — a one-step retry. There is no mid-run pause.
Problem type registry
Errors use RFC 9457 application/problem+json. type URIs live under
https://shiftagent.example.com/problems/{slug} (illustrative host — each
deployment substitutes its own). Every problem carries request_id;
conflict problems add conflicting_resource_id where applicable.
| Slug | Status | Meaning |
|---|---|---|
validation-error | 422 | Request body or parameters failed validation (errors[] lists pointers) |
not-found | 404 | Resource does not exist (or is outside the key’s subtree) |
name-conflict | 409 | Named sub-resource already exists (conflicting_resource_id) |
external-id-conflict | 409 | external_id already taken via plain create (conflicting_resource_id) |
cross-tenant | 409 | Referenced resource belongs to a different tenant |
conversation-archived | 409 | Write attempted on an archived conversation |
resource-in-use | 409 | Guarded delete refused; dependents exist (conflicting_resource_id) |
role-required | 422 | User has no role assigned; a conversation context cannot be resolved |
tenant-suspended | 403 | Tenant is suspended; conversation writes rejected |
insufficient-scope | 403 | Key or token lacks the required scope |
idempotency-key-conflict | 409 | Same Idempotency-Key, different request payload |
missing-secret | 422 | The run referenced a secret alias vaulted at no scope; re-send with the secret attached |
capacity-exhausted | 429 | No sandbox available (or max hold time exceeded); retry after Retry-After |
rate-limited | 429 | Too many requests; retry after Retry-After |
Authentication
Section titled “ Authentication ”integrationKey
Section titled “integrationKey ”The adapter’s service-principal integration key (Authorization: Bearer sk_int_…). Role-mode (no user directory behind it), scoped to the integration’s root tenant subtree. Grants the coarse scopes listed by getIntegrationSelf. Accepted by every authenticated operation.
Security scheme type: http
Bearer format: sk_int_… service key
platformJwt
Section titled “platformJwt ”Short-lived platform JWT minted by tokenExchange, carrying a single user’s identity. Accepted only by conversation and message operations, scoped to that user. Default TTL 15 minutes.
Security scheme type: http
Bearer format: JWT