Upsert a conversation by external ID (continue-vs-create).
const url = 'https://shiftagent.example.com/conversations/by-external-id/example';const options = { method: 'PUT', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"user_id":"usr_01hzx8jane001","title":"Invoice questions","metadata":{"host_ref":"ticket-4521"}}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request PUT \ --url https://shiftagent.example.com/conversations/by-external-id/example \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "user_id": "usr_01hzx8jane001", "title": "Invoice questions", "metadata": { "host_ref": "ticket-4521" } }'Idempotent get-or-create keyed on the host system’s conversation ID —
a natural key the host derives deterministically from its own
thread/ticket/call identifier. This is the continue-vs-create
primitive: if no conversation carries this external_id within the
tenant, one is created exactly like createConversation; otherwise
the existing conversation is continued.
201— created (new host thread): the context snapshot is resolved and a storage bucket auto-attached, exactly as increateConversation— including422role-requiredwhen the user has no role assigned.200— existed (continuing thread): provided fields were merged.
The body is the createConversation shape minus external_id (the
path supplies it). external_id is unique per tenant; under a
platformJwt the tenant comes from the token, under the
integrationKey it comes from user_id — which is therefore
required under the service key on every call, and required on the
create path under either credential (422 validation-error when
absent). Merge semantics per field: provided → replaced; omitted →
unchanged; null → cleared. Creation-time fields (user_id,
runtime.agent_type) are immutable — on the warm path they must be
omitted or match the existing record (422 validation-error
otherwise). Runtime placement (runtime.mode,
runtime.idle_ttl_seconds) is mutable: the most recent message’s
settings govern what happens next (an existing warm sandbox is
still used when present, even when the latest request says pooled
— it simply stops being kept warm afterward).
Races are safe: concurrent upserts of the same external_id
collapse on the per-tenant uniqueness constraint — the winner
receives 201, the loser 200 with the winner’s record. No 409
is possible on this path. (For an explicit conflict instead, use the
guarded style: getConversationByExternalId to check, then
createConversation with external_id, which responds 409
external-id-conflict when taken.)
Response shape depends on initial_message, exactly as in
createConversation: without it, the conversation JSON (201
created / 200 existed — the status code is the branch signal);
with it, 200 streaming application/x-ndjson whether the
conversation was created or continued — the conversation object
rides in the X-Shiftagent-Conversation-Id response header plus a
leading {"object":"platform.event","type":"conversation"} line, and
the assistant reply streams as the native agent event stream (see
createMessage for the full protocol). An archived conversation
rejects initial_message with 409 conversation-archived.
No Idempotency-Key is needed: PUT retries are inherently safe.
Authorizations
Section titled “Authorizations ”Parameters
Section titled “ Parameters ”Path Parameters
Section titled “Path Parameters ”The host system’s identifier, namespaced by the adapter (e.g. {ns}:tenant:{id} / {ns}:user:{id} / {ns}:conversation:{id}). Opaque to shiftagent — compared byte-exact after trimming; case-sensitive; URL-encode reserved characters (: → %3A where required by the client).
Request Body required
Section titled “Request Body required ”Merge-upsert body for upsertConversationByExternalId — the ConversationCreate shape minus external_id (the path supplies it). user_id is required on the create path, and on every call made under the integrationKey (it names the tenant whose external-ID namespace is addressed). Per field: provided → replaced, omitted → unchanged, null → cleared. Creation-time fields (user_id, runtime.agent_type) are immutable — on an existing conversation they must be omitted or match the record (422 validation-error otherwise).
object
Owning user. Required on the create path and under the integrationKey; immutable once the conversation exists.
Display title. Omit to leave unchanged; null to clear.
Narrowing within the resolved context skills; null clears the narrowing.
Runtime knobs. agent_type is creation-time; mode / idle_ttl_seconds manage warm placement on an existing conversation exactly as in updateConversation (the most recent message’s settings govern what happens next).
object
Agent runtime (open enum — e.g. claude-agent-sdk, codex, deepagent). Defaults from tenant settings. Immutable afterwards.
Placement mode.
Sliding idle timeout for warm mode. Capped by tenant.settings.max_idle_ttl_seconds (422 beyond the cap). Defaults to 300 when mode is warm.
Filler-agent enablement. Cascade: tenant settings → conversation → message; most specific wins. Filler output is part of the reply stream and the persisted message — indistinguishable from agent output by design.
object
Whether the low-latency filler agent runs for this scope.
Capacity strategy for initial_message: reject → 429 capacity-exhausted + Retry-After; hold → queue with queued platform notices (bounded by the deployment’s max hold time).
Replaces the metadata map wholesale when provided.
object
When present, the response is the NDJSON agent event stream (200, created or continued alike) instead of the conversation JSON; the conversation id rides in the X-Shiftagent-Conversation-Id header and a leading platform notice line.
object
The user’s message text.
Optional typed blocks (extensibility).
Typed content block — the extensibility seam for richer runs. Known types: text, tool_call, tool_result; unknown types must be ignored by clients.
object
Block type (open enum).
Text content (for text blocks).
Per-message skill narrowing — keeps the agent context lean. Must be within the conversation’s effective skills.
Plaintext, non-secret run parameters, visible to the agent verbatim. Never place secret material here — use secrets.
object
Write-only alias → value map, scoped to this message’s run (ephemeral — resolved before conversation-, user-, and tenant-scoped secrets). Never echoed anywhere; the agent sees only {{secret:ALIAS}} placeholders resolved by the egress proxy.
object
Per-message filler override (most specific wins).
object
Whether the low-latency filler agent runs for this scope.
reject → 429 capacity-exhausted + Retry-After when no sandbox is available; hold → the stream first emits queued platform notices until one frees (bounded by the deployment’s max hold time).
Free-form string key–value map for host/adapter bookkeeping (e.g. a host-side reference ID). Max 50 keys; values max 500 chars. Replaced wholesale when provided in updates.
object
Examples
Cold path — first contact from this host thread
{ "user_id": "usr_01hzx8jane001", "title": "Invoice questions", "metadata": { "host_ref": "ticket-4521" }}Continue-vs-create + first message in one round trip (streams)
{ "user_id": "usr_01hzx8jane001", "initial_message": { "content": "Summarize today's open jobs." }}Responses
Section titled “ Responses ”Conversation already existed — provided fields merged (the continuing-thread path); or, when initial_message is present, the NDJSON agent event stream (created or continued alike) with the conversation id in the X-Shiftagent-Conversation-Id header and a leading platform notice line.
An agent conversation owned by a user within a tenant. Snapshots its resolution context at creation and carries live runtime state.
object
Host system’s conversation ID (namespaced by the adapter). Unique per tenant; natural key for upsertConversationByExternalId — the continue-vs-create decision. Null for conversations created via plain createConversation without one.
Display title; auto-derivable from the first message.
Archived conversations keep readable history but reject message writes with 409 conversation-archived.
Resolution snapshot taken at conversation creation (user → role → repository → skills) — makes history self-explaining even after roles or repositories change.
object
The role the conversation was resolved under.
The effective repository at creation.
The effective skills at creation.
Optional narrowing within context.skill_ids; null means no narrowing.
Runtime placement state of a conversation. agent_type selects the agent runtime behind the platform’s runtime abstraction — each type runs in its own isolated sandbox.
object
Agent runtime — open enum so new runtimes are non-breaking. Known values: claude-agent-sdk, codex, deepagent. Defaults from tenant settings. Immutable after creation.
pooled — each message claims a warm-pool sandbox and releases it; warm — the conversation keeps its sandbox alive between messages under a sliding idle timeout (idle_ttl_seconds, reset on every message). The most recent message’s settings govern what happens next.
Sliding idle timeout of the warm sandbox; null for pooled conversations.
warm — a sandbox is currently held for this conversation (fast next message); cold — none held (pooled, warm-before-first- message, or the idle timer lapsed and the sandbox was recycled — the next message cold-starts and resumes the same session).
When the warm hold lapses (moves forward on every message); null when sandbox_state is cold.
Filler-agent enablement. Cascade: tenant settings → conversation → message; most specific wins. Filler output is part of the reply stream and the persisted message — indistinguishable from agent output by design.
object
Whether the low-latency filler agent runs for this scope.
S3-style storage bucket attached to a user or conversation. Platform-assigned automatically at creation; host-owned buckets can be linked via update (provider: "external").
object
platform — bucket provisioned and owned by the deployment; external — host-linked BYO bucket.
S3-style URI of the bucket root (e.g. s3://bucket/prefix).
Persisted message count (all roles).
Timestamp of the newest message; null when empty.
Free-form string key–value map for host/adapter bookkeeping (e.g. a host-side reference ID). Max 50 keys; values max 500 chars. Replaced wholesale when provided in updates.
object
RFC 3339 / ISO 8601 timestamp, UTC.
RFC 3339 / ISO 8601 timestamp, UTC.
Examples
Warm path — the host thread already maps to a conversation
{ "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": 4, "last_message_at": "2026-07-02T10:20:00Z", "metadata": { "host_ref": "ticket-4521" }, "created_at": "2026-07-02T10:00:00Z", "updated_at": "2026-07-02T10:20:00Z"}One NDJSON stream line. The stream is the platform’s native agent
event stream — raw Claude Agent SDK events passed through verbatim
(vocabulary versioned by the X-Shiftagent-Stream-Format response
header, currently claude-sdk/1) — interleaved with platform lines:
- Agent events carry a top-level
type—system,stream_event,assistant,user, and the terminalresult. {"event":"ping"}— keepalive.{"object":"platform.event","type":"queued","position":n, "retry_hint_seconds":n}— capacity hold notice.{"object":"platform.event","type":"conversation", "conversation":{…}}— leading line on create/upsert-with-initial_messagestreams, carrying the conversation.{"object":"platform.event","type":"error","problem":{…}}— terminal platform abort carrying an RFC 9457 problem (e.g.missing-secret).
Clients MUST ignore unknown line shapes (new event types are
additive). Exactly one terminal line (result or platform error)
ends every complete stream; a stream that closes without one is
truncated — reconcile via listMessages.
object
Example generated
{}Conversation created (new host thread; only without initial_message).
An agent conversation owned by a user within a tenant. Snapshots its resolution context at creation and carries live runtime state.
object
Host system’s conversation ID (namespaced by the adapter). Unique per tenant; natural key for upsertConversationByExternalId — the continue-vs-create decision. Null for conversations created via plain createConversation without one.
Display title; auto-derivable from the first message.
Archived conversations keep readable history but reject message writes with 409 conversation-archived.
Resolution snapshot taken at conversation creation (user → role → repository → skills) — makes history self-explaining even after roles or repositories change.
object
The role the conversation was resolved under.
The effective repository at creation.
The effective skills at creation.
Optional narrowing within context.skill_ids; null means no narrowing.
Runtime placement state of a conversation. agent_type selects the agent runtime behind the platform’s runtime abstraction — each type runs in its own isolated sandbox.
object
Agent runtime — open enum so new runtimes are non-breaking. Known values: claude-agent-sdk, codex, deepagent. Defaults from tenant settings. Immutable after creation.
pooled — each message claims a warm-pool sandbox and releases it; warm — the conversation keeps its sandbox alive between messages under a sliding idle timeout (idle_ttl_seconds, reset on every message). The most recent message’s settings govern what happens next.
Sliding idle timeout of the warm sandbox; null for pooled conversations.
warm — a sandbox is currently held for this conversation (fast next message); cold — none held (pooled, warm-before-first- message, or the idle timer lapsed and the sandbox was recycled — the next message cold-starts and resumes the same session).
When the warm hold lapses (moves forward on every message); null when sandbox_state is cold.
Filler-agent enablement. Cascade: tenant settings → conversation → message; most specific wins. Filler output is part of the reply stream and the persisted message — indistinguishable from agent output by design.
object
Whether the low-latency filler agent runs for this scope.
S3-style storage bucket attached to a user or conversation. Platform-assigned automatically at creation; host-owned buckets can be linked via update (provider: "external").
object
platform — bucket provisioned and owned by the deployment; external — host-linked BYO bucket.
S3-style URI of the bucket root (e.g. s3://bucket/prefix).
Persisted message count (all roles).
Timestamp of the newest message; null when empty.
Free-form string key–value map for host/adapter bookkeeping (e.g. a host-side reference ID). Max 50 keys; values max 500 chars. Replaced wholesale when provided in updates.
object
RFC 3339 / ISO 8601 timestamp, UTC.
RFC 3339 / ISO 8601 timestamp, UTC.
Examples
Cold path — new conversation for this host thread
{ "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"}Missing or invalid credentials — no bearer token, an unknown/revoked sk_int_ key, or an expired platform JWT.
RFC 9457 problem+json error envelope. type is a URI under https://shiftagent.example.com/problems/{slug} (deployment host substituted); see the API-level problem registry for every slug.
object
Problem type URI (registry slug).
Short, human-readable summary of the problem type.
HTTP status code.
Human-readable explanation specific to this occurrence.
URI reference identifying this occurrence.
Correlation ID for support and log lookup.
On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).
On validation-error, field-level details.
object
JSON pointer to the offending field.
What failed.
Examples
Missing or invalid bearer token
{ "type": "https://shiftagent.example.com/problems/insufficient-scope", "title": "Unauthorized", "status": 401, "detail": "Provide a valid sk_int_ service key or platform JWT.", "request_id": "req_01hzx8auth001"}Forbidden — tenant-suspended (writes to a suspended tenant) or insufficient-scope (key/token lacks the scope or a platform JWT reaches beyond its user).
RFC 9457 problem+json error envelope. type is a URI under https://shiftagent.example.com/problems/{slug} (deployment host substituted); see the API-level problem registry for every slug.
object
Problem type URI (registry slug).
Short, human-readable summary of the problem type.
HTTP status code.
Human-readable explanation specific to this occurrence.
URI reference identifying this occurrence.
Correlation ID for support and log lookup.
On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).
On validation-error, field-level details.
object
JSON pointer to the offending field.
What failed.
Examples
Suspended tenant rejects conversation writes
{ "type": "https://shiftagent.example.com/problems/tenant-suspended", "title": "Tenant suspended", "status": 403, "detail": "Tenant tnt_01hzx8acme001 is suspended; conversation writes are rejected.", "request_id": "req_01hzx8sus001"}Not found — the resource does not exist, was deprovisioned, or lies outside the integration key’s subtree (indistinguishable by design).
RFC 9457 problem+json error envelope. type is a URI under https://shiftagent.example.com/problems/{slug} (deployment host substituted); see the API-level problem registry for every slug.
object
Problem type URI (registry slug).
Short, human-readable summary of the problem type.
HTTP status code.
Human-readable explanation specific to this occurrence.
URI reference identifying this occurrence.
Correlation ID for support and log lookup.
On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).
On validation-error, field-level details.
object
JSON pointer to the offending field.
What failed.
Examples
Unknown resource
{ "type": "https://shiftagent.example.com/problems/not-found", "title": "Not found", "status": 404, "detail": "No tenant with external_id acme:tenant:999999.", "request_id": "req_01hzx8nf001"}Conflict — name-conflict / external-id-conflict (unique name or external ID taken; conflicting_resource_id names the holder — fetch it and continue), resource-in-use (guarded delete refused), cross-tenant (referenced resource belongs to another tenant), conversation-archived (write to an archived conversation), or idempotency-key-conflict (same key, different payload).
RFC 9457 problem+json error envelope. type is a URI under https://shiftagent.example.com/problems/{slug} (deployment host substituted); see the API-level problem registry for every slug.
object
Problem type URI (registry slug).
Short, human-readable summary of the problem type.
HTTP status code.
Human-readable explanation specific to this occurrence.
URI reference identifying this occurrence.
Correlation ID for support and log lookup.
On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).
On validation-error, field-level details.
object
JSON pointer to the offending field.
What failed.
Examples
Named sub-resource already exists — recoverable
{ "type": "https://shiftagent.example.com/problems/name-conflict", "title": "Name conflict", "status": 409, "detail": "A role named \"csr\" already exists in this tenant.", "conflicting_resource_id": "rol_01hzx8csr001", "request_id": "req_01hzx8conf01"}Guarded delete refused
{ "type": "https://shiftagent.example.com/problems/resource-in-use", "title": "Resource in use", "status": 409, "detail": "Repository is attached to 1 tenant and pinned by 2 roles.", "conflicting_resource_id": "tnt_01hzx8acme001", "request_id": "req_01hzx8used01"}Unprocessable — validation-error (schema/semantic validation failed; errors[] lists JSON-pointer details), role-required (the user has no role assigned, so no conversation context can resolve), or missing-secret (a referenced alias is vaulted at no scope).
RFC 9457 problem+json error envelope. type is a URI under https://shiftagent.example.com/problems/{slug} (deployment host substituted); see the API-level problem registry for every slug.
object
Problem type URI (registry slug).
Short, human-readable summary of the problem type.
HTTP status code.
Human-readable explanation specific to this occurrence.
URI reference identifying this occurrence.
Correlation ID for support and log lookup.
On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).
On validation-error, field-level details.
object
JSON pointer to the offending field.
What failed.
Examples
Field-level validation failure
{ "type": "https://shiftagent.example.com/problems/validation-error", "title": "Validation error", "status": 422, "detail": "One or more fields failed validation.", "errors": [ { "pointer": "/skill_access/skill_ids/0", "message": "skl_01hzx8unknown does not belong to the tenant's repository." } ], "request_id": "req_01hzx8val001"}User has no role at conversation creation
{ "type": "https://shiftagent.example.com/problems/role-required", "title": "Role required", "status": 422, "detail": "User usr_01hzx8jane001 has no role assigned; assign one before starting conversations.", "request_id": "req_01hzx8role01"}Too many requests — capacity-exhausted (no sandbox available, or the maximum hold time elapsed under on_capacity=hold) or rate-limited. Honor Retry-After.
RFC 9457 problem+json error envelope. type is a URI under https://shiftagent.example.com/problems/{slug} (deployment host substituted); see the API-level problem registry for every slug.
object
Problem type URI (registry slug).
Short, human-readable summary of the problem type.
HTTP status code.
Human-readable explanation specific to this occurrence.
URI reference identifying this occurrence.
Correlation ID for support and log lookup.
On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).
On validation-error, field-level details.
object
JSON pointer to the offending field.
What failed.
Examples
Sandbox pool exhausted (on_capacity=reject)
{ "type": "https://shiftagent.example.com/problems/capacity-exhausted", "title": "Capacity exhausted", "status": 429, "detail": "No sandbox available; retry after the indicated delay or use on_capacity=hold.", "request_id": "req_01hzx8cap001"}Headers
Section titled “Headers ”Seconds to wait before retrying.