Responders
A responder answers turns. It owns one reply's execution: given the conversation so far and the new input, it produces one reply. The session owns everything around it: the conversation's history, the journal and each turn's lifecycle.
Choosing a responder
| You want | Use |
|---|---|
| a normal model chat | createChat({ model, ... }), a model-backed responder |
| a conversational front door to a June workflow | agentResponder() |
| an existing service, or bespoke execution | your own Responder |
What any of them sees of earlier turns is decided by the chat's context, not by the responder.
Model chat
modelResponder({ model, instructions, ...settings }) answers turns with an AI SDK model. createChat({ model, instructions }) is shorthand for createChat({ responder: modelResponder({ model, instructions }) }). June doesn't wrap providers: the AI SDK and the provider package own the model, the protocol, auth, retries and errors, and June passes your LanguageModel straight to streamText.
import { openai } from "june/sdk/openai";
import { createChat, fileStore, modelResponder } from "june/chat";
const chat = createChat({
responder: modelResponder({
model: openai("gpt-5-mini"),
instructions: "You explain software briefly and plainly.",
temperature: 0.2,
maxOutputTokens: 800,
}),
store: fileStore(".june/chat"),
});
- What's sent.
instructionsas the system prompt, then the conversation as the chat's context built it, then the new input. Instructions are configuration: they're sent with every turn but never stored in the journal, so changing them changes later turns without rewriting history. - Settings.
maxOutputTokens,temperature,topP,topK,presencePenalty,frequencyPenalty,stopSequences,seed,maxRetries,headersandproviderOptionspass through tostreamTextunchanged. - Streaming. Text arrives as
text-deltaevents, andturn.result.textis the full reply. The completed turn recordsmetadata: themodelthat answered (providerandmodelId), tokenusage, and thefinishReason. Raw provider requests and responses are never stored. - Errors. A provider error, after the AI SDK's own retries, fails the turn with that error. Canceling a turn aborts the request.
- No tools. A model chat only talks. For tools, approvals and multi-step work, answer turns with a June workflow through
agentResponder(), where tool calls and approvals go through the runtime you already use.
Chat with a June agent
agentResponder() answers each turn by running a June workflow, the same <Agent>s, <Step>s and tools you'd run with june run:
import { openai } from "june/sdk/openai";
import { tool, jsonSchema, type ModelMessage } from "ai";
import { Agent, Step, Tool } from "june";
import { agentResponder, createChat, fileStore } from "june/chat";
const deploy = tool({
description: "Deploy to an environment",
inputSchema: jsonSchema<{ env: string }>({
type: "object",
properties: { env: { type: "string" } },
required: ["env"],
}),
needsApproval: true,
execute: async ({ env }) => `deployed to ${env}`,
});
function Engineer({
messages,
input,
}: {
messages: ModelMessage[];
input: string;
}) {
return (
<Agent model={openai("gpt-5-mini")} instructions="You ship code.">
<Tool name="deploy" value={deploy} />
<Step
id="reply"
prompt={[...messages, { role: "user", content: input }]}
/>
</Agent>
);
}
export default createChat({
responder: agentResponder({
id: "engineer",
version: "1",
render: ({ messages, input }) => (
<Engineer messages={messages} input={input} />
),
reply: (result) => String(result.steps.reply),
}),
store: fileStore(".june/chat"),
});
renderbuilds the workflow for one turn. It gets the turn'sinput, the completed conversation asmessages, and the session and turn ids. Nothing is injected into your agents: the conversation reaches the model only where you pass it, here as the step's prompt.replypicks the assistant's reply from the run'sRunResult, usually one step's result. June never guesses which step is the answer, so a multi-step workflow stays deterministic. It must return a string.runpassesconcurrencyandtimeouttostart().idandversionare recorded on each turn's provenance, withkind: "agent".startRunlaunches each turn's workflow,start()by default. It gets the rendered element, the run options (with the turn'ssignaland the responder'sonUpdatealready set) and the turn'ssessionIdandturnId, and returns theRunHandle. A host passes its own to register and observe the exact run a turn launches, as June Studio does; compose youronUpdatewith the responder's rather than replacing it, or the turn loses itsrun-updateevents.
src/examples/chat/agent.tsx is a two-step version, a draft and then a review whose result is the reply, that runs under june chat.
Sessions and runs
A session is the long-lived conversation; a run is one execution of the workflow. One session creates many runs, one per turn. Every turn starts its own run with start(), so everything the runtime does, it does here unchanged: steps, <Parallel>, <Catch>, concurrency, timeouts. A failure no <Catch> handles fails the turn. turn.result.run holds the run's RunResult, with its steps, trace and usage, for as long as you keep the result.
Chat isn't a second workflow runtime. It doesn't run steps, schedule tools or keep its own trace: it starts a run per turn, passes the conversation in through render, and takes one string back through reply.
What June preserves
A workflow turn is still a June run, so everything the runtime gives you is there in chat too.
Approvals
Tool calls that need approval go through the runtime's own approvals. The turn emits an approval-request event and lists the call in turn.requests; turn.approve(id) and turn.deny(id, reason) call the run's approve and deny. There's no second permission system:
import type { Turn } from "june/chat";
declare const turn: Turn;
for await (const event of turn) {
if (event.type === "approval-request") {
const { id, toolName, input } = event.request;
// Events are buffered, so skip a request the run has since withdrawn.
if (!turn.requests.some((request) => request.id === id)) continue;
if (toolName === "deploy" && (input as { env: string }).env !== "prod") {
turn.approve(id);
} else {
turn.deny(id, "Only non-prod deploys from chat");
}
}
}
june chat and june ask ask on stderr, like june run; the CLI reference has the details.
Traces
Each commit of the run arrives as a run-update event with a snapshot of the trace tree, the same tree onUpdate gives start(): steps, their status, tool calls, partial text, model and usage. Use it to show progress. Reply text arrives in turn.result unless you name the step that writes it; see Streaming the reply.
To show the trace in a UI, project each snapshot with projectChatTrace. It turns the tree into plain rows: steps with their tool calls, model calls and pending approvals, nested as the workflow nests them, each with a title that says its state ("Running research", "fetch failed") and a status of running, error or pending. Tool inputs and outputs become bounded summaries with credential-looking keys redacted, and a row's id stays the same from one snapshot to the next.
import { projectChatTrace, type ChatTraceRow, type Turn } from "june/chat";
declare const turn: Turn;
let rows: ChatTraceRow[] = [];
for await (const event of turn) {
if (event.type === "run-update") {
rows = projectChatTrace(event.tree, { requests: turn.requests });
}
}
An approval row carries the approvalId to pass to turn.approve or turn.deny. The rows are React-free, so any UI can draw them; june-design-system's Trace components fit them line for line. The runtime still owns status and lifecycle: the projection only reads the tree, and replacing your rows with each new snapshot's is all a UI has to do. June Studio's chat pane does exactly this, with a composition per row kind; see Studio's chat traces.
Streaming the reply
June never guesses which step is the reply, since a workflow can stream several. Name it with stream, and set stream on that <Step>:
import { Agent, Step } from "june";
import { agentResponder } from "june/chat";
import type { LanguageModel } from "ai";
declare const model: LanguageModel;
agentResponder({
render: ({ messages, input }) => (
<Agent model={model}>
<Step id="reply" stream prompt={[...messages, { role: "user", content: input }]} />
</Agent>
),
reply: (result) => String(result.steps.reply),
stream: { step: "reply" },
});
As the step writes, each new piece of its text arrives as a text-delta event, after the run-update that carried it, just like a model chat's, so june chat, june ask and June Studio show it as it's written. reply still picks the final text, and it must continue what streamed: anything left over arrives as one last text-delta, so the deltas always add up to turn.result.text, and a reply that contradicts the streamed text fails the turn rather than showing one answer and keeping another. Canceling stops the deltas with the run.
Stream a step that doesn't call tools. A step's partialText holds the text of every model call it makes, but its result is the last call's text, so a reply step that says "Let me check" before a tool call would stream text its result doesn't keep, and the turn fails. Put tool work in earlier steps and stream the step that writes the answer.
Canceling
turn.cancel() aborts the run, which aborts every step in flight, and records the turn as canceled.
What's stored
The journal keeps the reply and small provenance only: kind: "agent", your id and version, the model when every step used the same one, and the run's total usage. The trace, tool inputs and tool outputs are never written, since they can be large or sensitive. They're available live, in run-update events and turn.result.run, and gone when your process lets go of them. If you need them later, record them yourself from turn.result.run.
The contract
A responder has a respond() method. For each turn it gets:
input: the user's input, a string;messages: the conversation so far as AI SDKModelMessages, as the chat's context built it;signal: anAbortSignalthat aborts when the turn is canceled;sessionIdandturnId.
It returns an execution right away:
result, a promise of the reply'stextand optionalmetadata(model,usage,finishReason). June keeps only those fields.events, optional live events:text-delta,run-updateandapproval-request.requests,approve(id)anddeny(id, reason), when it asks for tool approvals.steer(input), when it can take more input while it runs. See Steer.
The responder can also set kind, id and version, which every turn it answers records as provenance.
Both built-in responders implement this contract, and so does yours.
Custom responders
A responder can be anything that returns an execution:
import { createChat, type ChatStore, type Responder } from "june/chat";
declare const store: ChatStore;
const shout: Responder = {
respond({ input, messages, signal }) {
const text = `${input.toUpperCase()} (${messages.length / 2} earlier turns)`;
async function* events() {
for (const word of text.split(" ")) {
signal.throwIfAborted();
yield { type: "text-delta" as const, text: `${word} ` };
}
}
return {
events: events(),
result: Promise.resolve({ text }),
};
},
};
const session = await createChat({ responder: shout, store }).createSession();
console.log((await session.send("hi").result).text);
resultresolves with the reply'stextand optionalmetadata(model,usage,finishReason). June keeps only those fields.- Set
kind,idandversionon the responder to record which one answered each turn. eventsis optional. June reads it once, in order, as the turn runs. The turn ends onceresultresolves andeventsends, so end the stream when the reply is done.- Honor
signalto stop work when the turn is canceled. - A responder that asks for tool approvals exposes
requests,approve(id)anddeny(id, reason); the turn's methods of the same names pass straight through. - A rejected
result, or aneventsstream that throws, fails the turn.
Provenance
Every completed turn records small, JSON-safe metadata about how it was answered:
responder: the responder'skind("model"for model chats,"agent"for workflow chats,"custom"by default), and itsidandversionwhen it sets them;model: theproviderandmodelId, when a model answered;usageandfinishReason, when the responder reports them.
That's all. API keys, auth headers, instructions, raw provider requests and responses, and full runtime traces are never written to the journal.
Provenance is what lets feedback be traced back to what produced the reply. Set version whenever what answers changes, a new prompt, model or workflow, so turns answered before and after the change stay distinguishable. The Copy Agent records the active agent's id as the version, so every reply says which agent wrote it. Datasets and eval cases built from sessions carry it in their metadata.