Skip to content

Upsert a conversation by external ID (continue-vs-create).

PUT
/conversations/by-external-id/{external_id}
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 in createConversation — including 422 role-required when 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.

external_id
required
string
>= 1 characters <= 255 characters

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).

Media type application/json

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
user_id

Owning user. Required on the create path and under the integrationKey; immutable once the conversation exists.

string
/^usr_[A-Za-z0-9]+$/
title

Display title. Omit to leave unchanged; null to clear.

string | null
<= 255 characters
selected_skill_ids

Narrowing within the resolved context skills; null clears the narrowing.

Array<string> | null
runtime

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_type

Agent runtime (open enum — e.g. claude-agent-sdk, codex, deepagent). Defaults from tenant settings. Immutable afterwards.

string
mode

Placement mode.

string
default: pooled
Allowed values: warm pooled
idle_ttl_seconds

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.

integer
>= 60 <= 86400
filler
One of:

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
enabled
required

Whether the low-latency filler agent runs for this scope.

boolean
on_capacity

Capacity strategy for initial_message: reject429 capacity-exhausted + Retry-After; hold → queue with queued platform notices (bounded by the deployment’s max hold time).

string
default: reject
Allowed values: reject hold
metadata

Replaces the metadata map wholesale when provided.

object
<= 50 properties
key
additional properties
string
<= 500 characters
initial_message

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
content
required

The user’s message text.

string
>= 1 characters
parts

Optional typed blocks (extensibility).

Array<object>

Typed content block — the extensibility seam for richer runs. Known types: text, tool_call, tool_result; unknown types must be ignored by clients.

object
type
required

Block type (open enum).

string
text

Text content (for text blocks).

string
key
additional properties
any
skill_ids

Per-message skill narrowing — keeps the agent context lean. Must be within the conversation’s effective skills.

Array<string>
env

Plaintext, non-secret run parameters, visible to the agent verbatim. Never place secret material here — use secrets.

object
key
additional properties
string
secrets

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
>= 1 properties
key
additional properties
string
filler

Per-message filler override (most specific wins).

object
enabled
required

Whether the low-latency filler agent runs for this scope.

boolean
on_capacity

reject429 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).

string
default: reject
Allowed values: reject hold
metadata

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
<= 50 properties
key
additional properties
string
<= 500 characters
Examples

Cold path — first contact from this host thread

{
"user_id": "usr_01hzx8jane001",
"title": "Invoice questions",
"metadata": {
"host_ref": "ticket-4521"
}
}

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
object
required
string
Allowed value: conversation
id
required
string
/^con_[A-Za-z0-9]+$/
external_id
required

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.

string | null
<= 255 characters
tenant_id
required
string
/^tnt_[A-Za-z0-9]+$/
user_id
required
string
/^usr_[A-Za-z0-9]+$/
title
required

Display title; auto-derivable from the first message.

string | null
<= 255 characters
status
required

Archived conversations keep readable history but reject message writes with 409 conversation-archived.

string
Allowed values: active archived
context
required

Resolution snapshot taken at conversation creation (user → role → repository → skills) — makes history self-explaining even after roles or repositories change.

object
role_id
required

The role the conversation was resolved under.

string
/^rol_[A-Za-z0-9]+$/
repository_id
required

The effective repository at creation.

string
/^rep_[A-Za-z0-9]+$/
skill_ids
required

The effective skills at creation.

Array<string>
selected_skill_ids
required

Optional narrowing within context.skill_ids; null means no narrowing.

Array<string> | null
runtime
required

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_type
required

Agent runtime — open enum so new runtimes are non-breaking. Known values: claude-agent-sdk, codex, deepagent. Defaults from tenant settings. Immutable after creation.

string
mode
required

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.

string
Allowed values: warm pooled
idle_ttl_seconds
required

Sliding idle timeout of the warm sandbox; null for pooled conversations.

integer | null
sandbox_state
required

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).

string
Allowed values: warm cold
expires_at
required

When the warm hold lapses (moves forward on every message); null when sandbox_state is cold.

string | null format: date-time
filler
required
One of:

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
enabled
required

Whether the low-latency filler agent runs for this scope.

boolean
storage
required

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
provider
required

platform — bucket provisioned and owned by the deployment; external — host-linked BYO bucket.

string
Allowed values: platform external
bucket_uri
required

S3-style URI of the bucket root (e.g. s3://bucket/prefix).

string
message_count
required

Persisted message count (all roles).

integer
last_message_at
required

Timestamp of the newest message; null when empty.

string | null format: date-time
metadata
required

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
<= 50 properties
key
additional properties
string
<= 500 characters
created_at
required

RFC 3339 / ISO 8601 timestamp, UTC.

string format: date-time
updated_at
required

RFC 3339 / ISO 8601 timestamp, UTC.

string format: date-time
Examples
Example warm_existed

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"
}

Conversation created (new host thread; only without initial_message).

Media type application/json

An agent conversation owned by a user within a tenant. Snapshots its resolution context at creation and carries live runtime state.

object
object
required
string
Allowed value: conversation
id
required
string
/^con_[A-Za-z0-9]+$/
external_id
required

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.

string | null
<= 255 characters
tenant_id
required
string
/^tnt_[A-Za-z0-9]+$/
user_id
required
string
/^usr_[A-Za-z0-9]+$/
title
required

Display title; auto-derivable from the first message.

string | null
<= 255 characters
status
required

Archived conversations keep readable history but reject message writes with 409 conversation-archived.

string
Allowed values: active archived
context
required

Resolution snapshot taken at conversation creation (user → role → repository → skills) — makes history self-explaining even after roles or repositories change.

object
role_id
required

The role the conversation was resolved under.

string
/^rol_[A-Za-z0-9]+$/
repository_id
required

The effective repository at creation.

string
/^rep_[A-Za-z0-9]+$/
skill_ids
required

The effective skills at creation.

Array<string>
selected_skill_ids
required

Optional narrowing within context.skill_ids; null means no narrowing.

Array<string> | null
runtime
required

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_type
required

Agent runtime — open enum so new runtimes are non-breaking. Known values: claude-agent-sdk, codex, deepagent. Defaults from tenant settings. Immutable after creation.

string
mode
required

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.

string
Allowed values: warm pooled
idle_ttl_seconds
required

Sliding idle timeout of the warm sandbox; null for pooled conversations.

integer | null
sandbox_state
required

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).

string
Allowed values: warm cold
expires_at
required

When the warm hold lapses (moves forward on every message); null when sandbox_state is cold.

string | null format: date-time
filler
required
One of:

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
enabled
required

Whether the low-latency filler agent runs for this scope.

boolean
storage
required

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
provider
required

platform — bucket provisioned and owned by the deployment; external — host-linked BYO bucket.

string
Allowed values: platform external
bucket_uri
required

S3-style URI of the bucket root (e.g. s3://bucket/prefix).

string
message_count
required

Persisted message count (all roles).

integer
last_message_at
required

Timestamp of the newest message; null when empty.

string | null format: date-time
metadata
required

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
<= 50 properties
key
additional properties
string
<= 500 characters
created_at
required

RFC 3339 / ISO 8601 timestamp, UTC.

string format: date-time
updated_at
required

RFC 3339 / ISO 8601 timestamp, UTC.

string format: date-time
Examples
Example cold_created

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.

Media type application/problem+json

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
type
required

Problem type URI (registry slug).

string format: uri-reference
title
required

Short, human-readable summary of the problem type.

string
status
required

HTTP status code.

integer format: int32
detail

Human-readable explanation specific to this occurrence.

string
instance

URI reference identifying this occurrence.

string format: uri-reference
request_id

Correlation ID for support and log lookup.

string
conflicting_resource_id

On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).

string
errors

On validation-error, field-level details.

Array<object>
object
pointer
required

JSON pointer to the offending field.

string
message
required

What failed.

string
Examples
Example unauthorized

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).

Media type application/problem+json

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
type
required

Problem type URI (registry slug).

string format: uri-reference
title
required

Short, human-readable summary of the problem type.

string
status
required

HTTP status code.

integer format: int32
detail

Human-readable explanation specific to this occurrence.

string
instance

URI reference identifying this occurrence.

string format: uri-reference
request_id

Correlation ID for support and log lookup.

string
conflicting_resource_id

On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).

string
errors

On validation-error, field-level details.

Array<object>
object
pointer
required

JSON pointer to the offending field.

string
message
required

What failed.

string
Examples
Example tenant_suspended

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).

Media type application/problem+json

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
type
required

Problem type URI (registry slug).

string format: uri-reference
title
required

Short, human-readable summary of the problem type.

string
status
required

HTTP status code.

integer format: int32
detail

Human-readable explanation specific to this occurrence.

string
instance

URI reference identifying this occurrence.

string format: uri-reference
request_id

Correlation ID for support and log lookup.

string
conflicting_resource_id

On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).

string
errors

On validation-error, field-level details.

Array<object>
object
pointer
required

JSON pointer to the offending field.

string
message
required

What failed.

string
Examples
Example not_found

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).

Media type application/problem+json

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
type
required

Problem type URI (registry slug).

string format: uri-reference
title
required

Short, human-readable summary of the problem type.

string
status
required

HTTP status code.

integer format: int32
detail

Human-readable explanation specific to this occurrence.

string
instance

URI reference identifying this occurrence.

string format: uri-reference
request_id

Correlation ID for support and log lookup.

string
conflicting_resource_id

On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).

string
errors

On validation-error, field-level details.

Array<object>
object
pointer
required

JSON pointer to the offending field.

string
message
required

What failed.

string
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"
}

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).

Media type application/problem+json

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
type
required

Problem type URI (registry slug).

string format: uri-reference
title
required

Short, human-readable summary of the problem type.

string
status
required

HTTP status code.

integer format: int32
detail

Human-readable explanation specific to this occurrence.

string
instance

URI reference identifying this occurrence.

string format: uri-reference
request_id

Correlation ID for support and log lookup.

string
conflicting_resource_id

On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).

string
errors

On validation-error, field-level details.

Array<object>
object
pointer
required

JSON pointer to the offending field.

string
message
required

What failed.

string
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"
}

Too many requests — capacity-exhausted (no sandbox available, or the maximum hold time elapsed under on_capacity=hold) or rate-limited. Honor Retry-After.

Media type application/problem+json

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
type
required

Problem type URI (registry slug).

string format: uri-reference
title
required

Short, human-readable summary of the problem type.

string
status
required

HTTP status code.

integer format: int32
detail

Human-readable explanation specific to this occurrence.

string
instance

URI reference identifying this occurrence.

string format: uri-reference
request_id

Correlation ID for support and log lookup.

string
conflicting_resource_id

On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).

string
errors

On validation-error, field-level details.

Array<object>
object
pointer
required

JSON pointer to the offending field.

string
message
required

What failed.

string
Examples
Example capacity_exhausted

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"
}
Retry-After
integer

Seconds to wait before retrying.