Skip to content

V2: decide how synthetic prompts enter the Session inbox #35966

Description

@kitlangton

Problem

Issue #35924 needs background completion to enter the durable Session inbox instead of publishing directly into transcript history. The first implementation added a parallel synthetic-input lifecycle beside prompt input. During review, that exposed a broader API question: synthetic messages can also be submitted intentionally by API users, so should they be a separate kind of Session input or a variant of the existing prompt API?

The decision affects public API shape, durable events, retry identity, delivery semantics, transcript projection, and how background completion composes with ordinary prompts.

Current API

The V2 SDK currently admits user input with:

await client.session.prompt({
  sessionID,
  id, // optional exact-retry identity
  prompt: {
    text: "Continue",
    files: [],
    agents: [],
  },
  delivery: "steer", // or "queue"
  resume: true,
})

This calls POST /api/session/:sessionID/prompt and returns the durable admitted input.

Synthetic transcript messages currently have a separate endpoint and Core method, but they bypass the inbox. The draft for #35924 gives them separate admitted/promoted events and a separate inbox payload.

Key observation

“Synthetic” describes how content appears in model history, not who authored or submitted it. A user, plugin, API client, background shell, or subagent may all intentionally submit synthetic content.

Candidate shapes

A. Prompt content is a union

await client.session.prompt({
  sessionID,
  prompt: {
    type: "synthetic",
    text: "Background task completed",
    description: "shell completion",
    metadata: { jobID },
  },
  delivery: "steer",
})

The existing user form becomes the other variant:

prompt: {
  type: "user",
  text: "Continue",
  files: [],
  agents: [],
}

Both variants share admission, promotion, ordering, retries, delivery, and wake behavior.

B. Session input is the public union

await client.session.input({
  sessionID,
  input:
    | { type: "prompt", prompt, delivery: "steer" | "queue" }
    | { type: "synthetic", text, description, metadata },
})

session.prompt() and session.synthetic() could remain convenience wrappers.

C. Keep parallel prompt and synthetic lifecycles

Retain separate methods, payloads, durable admitted/promoted events, and inbox records while preserving shared ordering in storage.

Questions to decide

  • Is a synthetic message a prompt content variant or a distinct Session input kind?
  • Should API-submitted synthetic content support both steer and queue, while background completion always chooses steer?
  • Should durable events be generic input admission/promotion events or content-specific prompt/synthetic events?
  • Does adding a required type: "user" discriminator before V2 launch improve the contract enough to justify changing the current prompt payload?
  • Should the existing synthetic endpoint remain as a convenience alias, or should all admission use the prompt/input endpoint?
  • What should exact retry compare: the complete content variant plus delivery mode, as prompt retries do today?

Required behavior

Regardless of API shape:

  • Admission is durable before execution is woken.
  • Prompt and synthetic inputs preserve one admission order.
  • Exact ID retries reconcile; conflicting reuse fails.
  • Background completion uses steering semantics and is promoted only at a safe step boundary.
  • An active continuation receives a coalesced follow-up wake.
  • Queue, compaction, staged revert, interruption/restart, fork, and replay behavior remain deterministic.

Related

Gang-grill decision — 2026-07-09

Source: Slack thread

Status: Kit selected this design after discussion with Dax; Dax's final confirmation has been requested in the source thread.

Public SDK

Keep separate public methods because synthetic input is the lower-level operation, while sharing one admission implementation and lifecycle. Flatten the current nested prompt payload:

await client.session.prompt({
  sessionID,
  id,
  text,
  files,
  agents,
  metadata,
  delivery: "steer", // or "queue"; defaults to "steer"
  resume: true, // default
})

await client.session.synthetic({
  sessionID,
  id,
  text,
  description,
  metadata,
  delivery: "steer", // or "queue"; defaults to "steer"
  resume: true, // default
})
  • prompt() admits a user message; synthetic() admits a synthetic message.
  • Both support steer and queue, defaulting to steer.
  • Both default resume to true; resume: false means admit without waking execution.
  • Background completion uses synthetic() with steering semantics.
  • Exact ID retries compare the complete message payload, including metadata, plus delivery. resume is a wake instruction, not durable input identity.
  • Synthetic text is model-facing. Its optional description is the UI-facing notice; synthetic messages without a description may remain hidden.

Inbox model and storage

The inbox is an ordered admission mechanism for the subset of Session messages that callers may submit, not a prompt-specific lifecycle. Initially that subset is user and synthetic messages. Assistant messages are never admitted through this API. Compaction remains an internal control entry in the same inbox.

Make session_input parallel to session_message by using a direct discriminator plus variant data:

session_input
  id
  session_id
  type             # user | synthetic | compaction
  data             # variant-specific JSON
  delivery         # null for compaction
  admitted_seq
  promoted_seq     # null while pending
  time_admitted

User input data contains text, optional files, optional agents, and optional metadata. Synthetic input data contains text, optional description, and optional metadata.

Promotion projects a pending user or synthetic input into the corresponding session_message representation at a safe boundary. All admitted message inputs share one durable order. A distinct inbox table keeps pending content structurally absent from visible transcript queries.

Durable events and timestamps

Use generic durable message-input lifecycle events for both public variants:

session.input.admitted
session.input.promoted

The admitted event carries the user-or-synthetic input payload and delivery mode; consumers inspect the payload discriminator. Compaction keeps its separate control lifecycle.

Keep current prompt timestamp semantics and field names: SessionInput.timeCreated / session_input.time_created is admission time, while promoted SessionMessage.time.created is promotion time. Applying this existing split to synthetic input prevents queued messages from making visible transcript timestamps run backward.

Metadata

Metadata

Assignees

Labels

2.0gang-grillDesign topics queued for later group grilling

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions