This guide covers everything you need to run a saved agent: open a session, send turns, consume the event stream, and handle pauses, threads, and disconnects.
Install and connect
Running agents uses the gateway SDK, not the truefoundry client.
pip install -U truefoundry-gateway-sdk
export TFY_GATEWAY_URL="https://<your-gateway-host>/<tenant>"
export TFY_API_KEY="<your-api-key>"
For TypeScript, install truefoundry-gateway-sdk from npm. Both languages import AgentSessionClient — Python from truefoundry_gateway_sdk.agents, TypeScript from truefoundry-gateway-sdk/agents. Set TFY_GATEWAY_URL and TFY_API_KEY as above. Every example below comes in both languages.
Create a session
A session is the conversation context for a saved agent — one customer working through one issue. It persists across many turns; persist session.id to resume later.
Open a session
A Session is the conversation context for a saved agent. Create one by name with create.
import os
from truefoundry_gateway_sdk.agents import AgentSessionClient
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
# A Session is the conversation context for a saved agent.
session = client.create_session(agent_name="support-bot")
import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
// A Session is the conversation context for a saved agent.
const session = await client.createSession({ agentName: "support-bot" });
List sessions for an agent
List the existing sessions for a saved agent with list. It iterates newest-first and auto-paginates. Use it to resume an earlier conversation — each Session exposes its id (persist or reuse it to continue).
import os
from truefoundry_gateway_sdk.agents import AgentSessionClient
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
for session in client.list_sessions(agent_name="support-bot"):
print("session id:", session.id)
print("created at:", session.created_at)
import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
for await (const session of await client.listSessions({ agentName: "support-bot" })) {
console.log("session id:", session.id);
console.log("created at:", session.createdAt);
}
Create a turn
A turn is one request/response cycle. Send input, the agent runs until it finishes or pauses, then the stream closes. Create another turn on the same session to continue — turns chain automatically.
Create and stream a turn
Create a turn with session.prepare_turn(), then call turn.execute(stream=True) to start it and stream its events as they arrive. The first event is always turn.created (carrying the turn_id); the stream closes with turn.done.
When the stream runs to completion, read the terminal state from turn.state. If you break out of the loop early, call turn.refresh() first.
To continue the conversation, create another Turn on the same session. Turns within a session are chained automatically, so each Turn sees the history of the ones before it.
Creating a new Turn in a session automatically cancels any other Turn that is still running in that session.
import os
from truefoundry_gateway_sdk.agents import AgentSessionClient
from truefoundry_gateway_sdk.types import UserMessage
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.create_session(agent_name="support-bot")
# Each turn is one request/response cycle.
turn = session.prepare_turn(
input=[UserMessage(content="I would like to file a support ticket.")],
)
for data in turn.execute(stream=True):
print(data.event)
# When the stream runs to completion, state is already up to date.
turn_state = turn.state
print("final status:", turn_state.status)
# Create another turn on the same session. Turns are chained automatically,
# so this turn sees the history of the first one.
turn = session.prepare_turn(
input=[UserMessage(content="Use my last order, ORD-2031.")],
)
for data in turn.execute(stream=True):
print(data.event)
import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.createSession({ agentName: "support-bot" });
// Each Turn is one request/response cycle.
let turn = session.prepareTurn({
input: [{ type: "user.message", content: "I would like to file a support ticket." }],
});
for await (const data of turn.execute({ stream: true })) {
console.log(data.event);
}
// When the stream runs to completion, state is already up to date. If you break out of
// the loop early, call await turn.refresh() to fetch the latest state from the server.
const turnState = turn.state;
console.log("final status:", turnState.status);
// Create another Turn on the same session. Turns are chained automatically,
// so this turn sees the history of the first one.
turn = session.prepareTurn({
input: [{ type: "user.message", content: "Use my last order, ORD-2031." }],
});
for await (const data of turn.execute({ stream: true })) {
console.log(data.event);
}
List turns
List the turns in a session with listTurns() / list_turns(). Each turn exposes its input, and state holds the current lifecycle state.
list_turns() always returns turns in descending order (newest-first).
import os
from truefoundry_gateway_sdk.agents import (
AgentSessionClient,
TurnStateRunning,
TurnStateDone,
TurnStateCancelled,
TurnStateError,
)
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.get_session(session_id="sess-abc123")
# list_turns() always returns turns in descending order (newest-first).
for turn in session.list_turns():
turn_state = turn.state
print("turn id:", turn.id)
# input is the list that started the turn: UserMessage(s), or
# UserToolApprovalEvent / UserToolResponseEvent items.
print("input:", turn.input)
if isinstance(turn_state, TurnStateRunning):
print("status: running (no output yet)")
elif isinstance(turn_state, TurnStateDone):
print("status: done")
# output is the final assistant message (model.message), or None.
if turn_state.output is not None:
print("output:", turn_state.output.content)
# required_actions lists any pause events the turn surfaced
# (tool.approval_required, tool.response_required, mcp.auth_required).
for item in turn_state.required_actions:
print("required action:", item)
elif isinstance(turn_state, TurnStateCancelled):
print("status: cancelled", turn_state.reason)
elif isinstance(turn_state, TurnStateError):
print("status: error", turn_state.message)
import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.getSession({ sessionId: "sess-abc123" });
// listTurns() always returns Turns in descending order (newest-first).
for await (const turn of await session.listTurns()) {
const turnState = turn.state;
console.log("turn id:", turn.id);
// input is the list that started the turn: user.message items, or
// user.tool_approval / user.tool_response items.
console.log("input:", turn.input);
if (turnState.status === "running") {
console.log("status: running (no output yet)");
} else if (turnState.status === "done") {
console.log("status: done");
// output is the final assistant message (model.message), or null.
if (turnState.output != null) {
console.log("output:", turnState.output.content);
}
// requiredActions lists any pause events the Turn surfaced
// (tool.approval_required, tool.response_required, mcp.auth_required).
for (const item of turnState.requiredActions) {
console.log("required action:", item);
}
} else if (turnState.status === "cancelled") {
console.log("status: cancelled", turnState.reason);
} else if (turnState.status === "error") {
console.log("status: error", turnState.message);
}
}
Non-streaming turn
If you don’t need live events, call turn.execute(stream=False) and read the returned terminal state (or read turn.state after consuming a full stream).
import os
from truefoundry_gateway_sdk.agents import (
AgentSessionClient,
TurnStateDone,
TurnStateCancelled,
TurnStateError,
)
from truefoundry_gateway_sdk.types import UserMessage
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.create_session(agent_name="support-bot")
turn = session.prepare_turn(
input=[UserMessage(content="Summarize my last order.")],
)
turn_state = turn.execute(stream=False)
if isinstance(turn_state, TurnStateDone):
# output and required_actions are mutually exclusive: if output is present,
# required_actions is empty, and vice versa.
# output is the final assistant message, or None if the turn paused.
if turn_state.output is not None:
print(turn_state.output.content)
# required_actions holds any pause events (tool.approval_required,
# tool.response_required, mcp.auth_required).
else:
print("required actions:", turn_state.required_actions)
elif isinstance(turn_state, TurnStateCancelled):
print("cancelled:", turn_state.reason)
elif isinstance(turn_state, TurnStateError):
print("error:", turn_state.message)
print("completed at:", turn_state.completed_at)
import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.createSession({ agentName: "support-bot" });
const turn = session.prepareTurn({
input: [{ type: "user.message", content: "Summarize my last order." }],
});
const turnState = await turn.execute({ stream: false });
if (turnState.status === "done") {
// output and requiredActions are mutually exclusive: if output is present,
// requiredActions is empty, and vice versa.
// output is the final assistant message, or null if the Turn paused.
if (turnState.output != null) {
console.log(turnState.output.content);
// requiredActions holds any pause events (tool.approval_required,
// tool.response_required, mcp.auth_required).
} else {
console.log("required actions:", turnState.requiredActions);
}
} else if (turnState.status === "cancelled") {
console.log("cancelled:", turnState.reason);
} else if (turnState.status === "error") {
console.log("error:", turnState.message);
}
console.log("completed at:", turnState.completedAt);
Attach images or files to a turn
A UserMessage’s content can be a plain string or a list of content parts. Use content parts to send text alongside one or more file uploads (images, PDFs, and other documents). Each file is passed as a data URI of the form data:<mime>;base64,<payload>.
import base64
import os
from truefoundry_gateway_sdk.agents import AgentSessionClient
from truefoundry_gateway_sdk.types import (
UserMessage,
TextContent,
FileContent,
)
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.create_session(agent_name="support-bot")
def to_data_uri(path: str, mime: str) -> str:
with open(path, "rb") as f:
return f"data:{mime};base64,{base64.b64encode(f.read()).decode()}"
# Attach both an image and a document in the same message.
image_uri = to_data_uri("invoice.png", "image/png")
doc_uri = to_data_uri("notes.txt", "text/plain")
turn = session.prepare_turn(
input=[
UserMessage(
content=[
TextContent(text="Does the invoice total match the notes?"),
FileContent(name="invoice.png", data=image_uri),
FileContent(name="notes.txt", data=doc_uri),
]
)
],
)
for data in turn.execute(stream=True):
print(data.event)
import { readFileSync } from "node:fs";
import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.createSession({ agentName: "support-bot" });
function toDataUri(path: string, mime: string): string {
return `data:${mime};base64,${readFileSync(path).toString("base64")}`;
}
// Attach both an image and a document in the same message.
const imageUri = toDataUri("invoice.png", "image/png");
const docUri = toDataUri("notes.txt", "text/plain");
const turn = session.prepareTurn({
input: [
{
type: "user.message",
content: [
{ type: "text", text: "Does the invoice total match the notes?" },
{ type: "file", name: "invoice.png", data: imageUri },
{ type: "file", name: "notes.txt", data: docUri },
],
},
],
});
for await (const data of turn.execute({ stream: true })) {
console.log(data.event);
}
Whether an attached file is understood depends on the agent’s model. Send images only to a vision-capable model; document handling (for example, PDFs) likewise depends on model and agent configuration.
Non-image files such as PDFs require the sandbox to be enabled on the agent - the harness uses it to process the document.
Cancel a turn
To stop the currently running turn, call cancel on the session. Cancelling aborts any in-flight model request, waits for running MCP tool calls to finish, and force-stops any sandbox the turn provisioned. Cancellation is idempotent. You can continue by creating a new turn, which chains on the cancelled turn’s history.
import os
from truefoundry_gateway_sdk.agents import AgentSessionClient
from truefoundry_gateway_sdk.types import UserMessage
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.create_session(agent_name="support-bot")
# Start a turn, then cancel the session partway through the stream.
turn = session.prepare_turn(
input=[UserMessage(content="Run a long research task.")],
)
i = 0
for data in turn.execute(stream=True):
print(data.event)
if i == 5:
# Don't break here. After cancel(), the backend closes the SSE stream
# gracefully: it emits a terminal turn.done event and then ends the
# stream, which closes this iterator on its own.
session.cancel()
i += 1
# A cancelled turn is terminal, so its event log is stored and can be replayed.
for event in turn.list_events(order="asc"):
print(event)
# Continue the conversation: a new turn chains on the cancelled turn's history.
next_turn = session.prepare_turn(
input=[UserMessage(content="Actually, just give me a quick summary instead.")],
)
for data in next_turn.execute(stream=True):
print(data.event)
import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.createSession({ agentName: "support-bot" });
// Start a turn, then cancel the session partway through the stream.
const turn = session.prepareTurn({
input: [{ type: "user.message", content: "Run a long research task." }],
});
let i = 0;
for await (const data of turn.execute({ stream: true })) {
console.log(data.event);
if (i === 5) {
// Don't break here. After cancel(), the backend closes the SSE stream
// gracefully: it emits a terminal turn.done event and then ends the
// stream, which closes this iterator on its own. Keep consuming until
// the loop finishes so cancellation completes cleanly.
await session.cancel();
}
i += 1;
}
// A cancelled turn is terminal, so its event log is stored and can be replayed.
for await (const event of await turn.listEvents({ order: "asc" })) {
console.log(event);
}
// Continue the conversation: a new turn chains on the cancelled turn's history.
const nextTurn = session.prepareTurn({
input: [{ type: "user.message", content: "Actually, just give me a quick summary instead." }],
});
for await (const data of nextTurn.execute({ stream: true })) {
console.log(data.event);
}
Handle MCP outbound auth
When the agent needs to call a tool on an MCP server that requires a separate outbound authentication, it emits an mcp.auth_required event and the Turn ends. The event lists each server that needs authentication along with an auth_url. Depending on how the server is configured, this may be an OAuth flow or an API-key entry - see MCP authentication scenarios for details. Send the user to that URL to complete authentication, then resume by creating a new Turn.
When the previous Turn ended with mcp.auth_required, passing a UserMessage in the resuming Turn is not allowed.
import os
from truefoundry_gateway_sdk.agents import AgentSessionClient, McpAuthRequiredEvent
from truefoundry_gateway_sdk.types import UserMessage
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.create_session(agent_name="support-bot")
# First turn: the agent needs an MCP server the user hasn't authorized yet.
# A turn emits at most one mcp.auth_required event.
pending_auth = None
turn = session.prepare_turn(
input=[UserMessage(content="Summarize my latest GitHub issues.")],
)
for data in turn.execute(stream=True):
event = data.event
print(event)
if isinstance(event, McpAuthRequiredEvent):
pending_auth = event
# Direct the user to each server's auth URL and wait for them to finish.
for server in pending_auth.mcp_servers:
print(f"Authorize {server.name}: {server.auth_url}")
input("Press Enter once you have authorized the server(s)...")
# Second turn: resume — the agent continues the interrupted work.
turn = session.prepare_turn()
for data in turn.execute(stream=True):
print(data.event)
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.createSession({ agentName: "support-bot" });
const rl = createInterface({ input: stdin, output: stdout });
// First turn: the agent needs an MCP server the user hasn't authorized yet.
// A Turn emits at most one mcp.auth_required event.
let pendingAuth;
let turn = session.prepareTurn({
input: [{ type: "user.message", content: "Summarize my latest GitHub issues." }],
});
for await (const data of turn.execute({ stream: true })) {
const message = data.event;
console.log(message);
if (message.type === "mcp.auth_required") {
pendingAuth = message;
}
}
// Direct the user to each server's auth URL and wait for them to finish.
for (const server of pendingAuth.mcpServers) {
console.log(`Authorize ${server.name}: ${server.authUrl}`);
}
await rl.question("Press Enter once you have authorized the server(s)...");
// Second turn: resume - the agent continues the interrupted work.
turn = session.prepareTurn();
for await (const data of turn.execute({ stream: true })) {
console.log(data.event);
}
rl.close();
When a tool call is configured to require human approval, the agent emits a tool.approval_required event and the turn ends. Each event carries a thread_id and the tool_calls awaiting a decision — a single turn can emit more than one (for example, when parallel threads each call a gated tool), so collect all of them. Resume by creating a new turn with one UserToolApprovalEvent per pending tool call — allow it, or deny it with an optional reason.
Each pending ToolCallRef carries the source_event_id of the model.message that emitted the tool call. Keep the same id-keyed event index you build while streaming, then look up events[source_event_id] to read the tool’s name and arguments — no separate bookkeeping required.
import os
from truefoundry_gateway_sdk.agents import (
AgentSessionClient,
ToolApprovalRequiredEvent,
TurnEvent,
UserToolApprovalEvent,
is_event_delta,
merge_event_delta,
)
from truefoundry_gateway_sdk.types import ApprovalAllow, ApprovalDeny, UserMessage
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.create_session(agent_name="support-bot")
# First turn: the agent calls one or more tools that need approval.
events: dict[str, TurnEvent] = {} # event id -> assembled event
pending_approvals = [] # a turn can emit multiple tool.approval_required events
turn = session.prepare_turn(
input=[UserMessage(content="Restart the billing service.")],
)
for data in turn.execute(stream=True):
message = data.event
if is_event_delta(message):
base = events.get(message.id)
if base is not None:
merge_event_delta(base, message)
else:
events[message.id] = message
if isinstance(message, ToolApprovalRequiredEvent):
pending_approvals.append(message)
# Decide on each pending tool call, showing its name and arguments.
approvals = []
for pending in pending_approvals:
for ref in pending.tool_calls:
# Look up the model.message that emitted this tool call, then find the call.
model_message = events[ref.source_event_id]
tool_call = next(tc for tc in (model_message.tool_calls or []) if tc.id == ref.id)
print(f"\napproval needed: {tool_call.tool_info.name}")
print(f" arguments: {tool_call.function.arguments}")
answer = input("allow? [y/n] ").strip().lower()
approvals.append(
UserToolApprovalEvent(
thread_id=pending.thread_id,
tool_call_id=ref.id,
approval=ApprovalAllow() if answer == "y" else ApprovalDeny(reason="denied by user"),
)
)
# Second turn: resume with the approval decisions.
turn = session.prepare_turn(input=approvals)
for data in turn.execute(stream=True):
print(data.event)
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import {
AgentSessionClient,
isEventDelta,
mergeEventDelta,
type TurnEvent,
} from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.createSession({ agentName: "support-bot" });
const rl = createInterface({ input: stdin, output: stdout });
// First turn: the agent calls one or more tools that need approval.
const events = new Map<string, TurnEvent>(); // event id -> assembled event
const pendingApprovals = []; // a Turn can emit multiple tool.approval_required events
let turn = session.prepareTurn({
input: [{ type: "user.message", content: "Restart the billing service." }],
});
for await (const data of turn.execute({ stream: true })) {
const message = data.event;
if (isEventDelta(message)) {
const base = events.get(message.id);
if (base) mergeEventDelta(base, message);
} else {
events.set(message.id, message);
}
if (message.type === "tool.approval_required") {
pendingApprovals.push(message);
}
}
// Decide on each pending tool call, showing its name and arguments.
const approvals = [];
for (const pending of pendingApprovals) {
for (const ref of pending.toolCalls) {
// Look up the model.message that emitted this tool call, then find the call.
const modelMessage = events.get(ref.sourceEventId)!;
const toolCall = modelMessage.toolCalls!.find((tc) => tc.id === ref.id)!;
console.log(`\napproval needed: ${toolCall.toolInfo.name}`);
console.log(` arguments: ${toolCall.function.arguments}`);
const answer = (await rl.question("allow? [y/n] ")).trim().toLowerCase();
approvals.push({
type: "user.tool_approval",
threadId: pending.threadId,
toolCallId: ref.id,
approval: answer === "y" ? { status: "allow" } : { status: "deny", reason: "denied by user" },
});
}
}
// Second turn: resume with the approval decisions.
turn = session.prepareTurn({ input: approvals });
for await (const data of turn.execute({ stream: true })) {
console.log(data.event);
}
rl.close();
Answer agent questions
When the agent needs input it cannot safely assume, it can ask the user a structured question via the built-in client-side ask_user_question tool. Since the tool runs on your side, the agent emits a tool.response_required event and the turn ends. The pending tool call’s arguments carry the question and its options. Collect the user’s answer and resume by creating a new turn with one UserToolResponseEvent per pending tool call.
Each pending ToolCallRef carries the source_event_id of the model.message that emitted the tool call. Keep the same id-keyed event index you build while streaming, then look up events[source_event_id] to read the tool’s name and arguments — no separate bookkeeping required.
import json
import os
from truefoundry_gateway_sdk.agents import (
AgentSessionClient,
ToolResponseRequiredEvent,
TurnEvent,
UserToolResponseEvent,
is_event_delta,
merge_event_delta,
)
from truefoundry_gateway_sdk.types import TrueFoundrySystemToolInfo, UserMessage
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.create_session(agent_name="support-bot")
# First turn: the agent asks the user a question.
events: dict[str, TurnEvent] = {} # event id -> assembled event
pending_questions = [] # a turn can emit multiple tool.response_required events
turn = session.prepare_turn(
input=[UserMessage(content="Create a new environment called testing-1.")],
)
for data in turn.execute(stream=True):
message = data.event
if is_event_delta(message):
base = events.get(message.id)
if base is not None:
merge_event_delta(base, message)
else:
events[message.id] = message
if isinstance(message, ToolResponseRequiredEvent):
pending_questions.append(message)
# Answer each pending question, reading the prompt and options from arguments.
responses = []
for pending in pending_questions:
for ref in pending.tool_calls:
# Look up the model.message that emitted this tool call, then find the call.
model_message = events[ref.source_event_id]
tool_call = next(tc for tc in (model_message.tool_calls or []) if tc.id == ref.id)
# tool.response_required covers any client-side tool; handle ask_user_question here.
if not (
isinstance(tool_call.tool_info, TrueFoundrySystemToolInfo)
and tool_call.tool_info.name == "ask_user_question"
):
continue
args = json.loads(tool_call.function.arguments or "{}")
print(f"\n{args.get('question', '')}")
for idx, option in enumerate(args.get("options") or [], 1):
print(f" {idx}. {option}")
answer = input("your answer: ").strip()
responses.append(
UserToolResponseEvent(
thread_id=pending.thread_id,
tool_call_id=ref.id,
# content is a free-form string — the chosen option, or any text.
content=answer,
)
)
# Second turn: resume with the user's answers.
turn = session.prepare_turn(input=responses)
for data in turn.execute(stream=True):
print(data.event)
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import {
AgentSessionClient,
isEventDelta,
mergeEventDelta,
type TurnEvent,
} from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.createSession({ agentName: "support-bot" });
const rl = createInterface({ input: stdin, output: stdout });
// First turn: the agent asks the user a question.
const events = new Map<string, TurnEvent>(); // event id -> assembled event
const pendingQuestions = []; // a Turn can emit multiple tool.response_required events
let turn = session.prepareTurn({
input: [{ type: "user.message", content: "Create a new environment called testing-1." }],
});
for await (const data of turn.execute({ stream: true })) {
const message = data.event;
if (isEventDelta(message)) {
const base = events.get(message.id);
if (base) mergeEventDelta(base, message);
} else {
events.set(message.id, message);
}
if (message.type === "tool.response_required") {
pendingQuestions.push(message);
}
}
// Answer each pending question, reading the prompt and options from arguments.
const responses = [];
for (const pending of pendingQuestions) {
for (const ref of pending.toolCalls) {
// Look up the model.message that emitted this tool call, then find the call.
const modelMessage = events.get(ref.sourceEventId)!;
const toolCall = modelMessage.toolCalls!.find((tc) => tc.id === ref.id)!;
// tool.response_required covers any client-side tool; handle ask_user_question here.
if (!(toolCall.toolInfo?.type === "truefoundry-system" && toolCall.toolInfo.name === "ask_user_question")) {
continue;
}
const args = JSON.parse(toolCall.function.arguments || "{}");
console.log(`\n${args.question ?? ""}`);
(args.options ?? []).forEach((option, idx) => {
console.log(` ${idx + 1}. ${option}`);
});
const answer = (await rl.question("your answer: ")).trim();
responses.push({
type: "user.tool_response",
threadId: pending.threadId,
toolCallId: ref.id,
// content is a free-form string - the chosen option, or any text.
content: answer,
});
}
}
// Second turn: resume with the user's answers.
turn = session.prepareTurn({ input: responses });
for await (const data of turn.execute({ stream: true })) {
console.log(data.event);
}
rl.close();
Subscribe to events
Every turn emits a stream of events over SSE. The stream opens with turn.created and closes with turn.done. See the Turn events reference for every event type.
Handling Event Delta while streaming
Most events in a Turn are complete on their own - a single payload you can use directly. Some updates are streamed instead: the base event arrives first, followed by a series of Event Deltas - incremental fragments that you merge into the base. All deltas for one update share the base event’s id, while sequence_number increases across the base and all its deltas.
// Base assembled event arrives first
{ "type": "model.message", "id": "0f3a9c2b-7d41-4e8a-b2c6-1a5f9e3d2b48", "sequence_number": 6, "content": "" }
// Deltas follow: same id, increasing sequence_number, each merged into the base
{ "type": "model.message.delta", "id": "0f3a9c2b-7d41-4e8a-b2c6-1a5f9e3d2b48", "sequence_number": 7, "content": "Hel" }
{ "type": "model.message.delta", "id": "0f3a9c2b-7d41-4e8a-b2c6-1a5f9e3d2b48", "sequence_number": 8, "content": "lo" }
{ "type": "model.message.delta", "id": "0f3a9c2b-7d41-4e8a-b2c6-1a5f9e3d2b48", "sequence_number": 9, "content": "!", "finish_reason": "stop" }
// After merging every delta, the base holds the full message
{ "type": "model.message", "id": "0f3a9c2b-7d41-4e8a-b2c6-1a5f9e3d2b48", "content": "Hello!", "finish_reason": "stop" }
Because every delta carries the base event’s id, keep an id-keyed index of assembled events: store each non-delta event under its id, and merge each delta into the base with the same id. The id is unique per message, so deltas from concurrently streaming threads (the main agent and any sub-agents) always merge into the right base.
Both languages ship is_event_delta and merge_event_delta from truefoundry_gateway_sdk.agents (Python) and truefoundry-gateway-sdk/agents (TypeScript).
Event Deltas appear only while streaming. When you list turn events via the events API, the deltas are already merged into a single assembled event.
The most common example is the assistant’s model output: a base ModelMessageEvent followed by ModelMessageDeltaEvent deltas that carry incremental text and tool-call chunks. Merge the deltas into the base as they arrive — read the base’s growing content for a live typing effect.
import os
from truefoundry_gateway_sdk.agents import (
AgentSessionClient,
TurnEvent,
is_event_delta,
merge_event_delta,
)
from truefoundry_gateway_sdk.types import UserMessage
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.create_session(agent_name="support-bot")
events: dict[str, TurnEvent] = {} # event id -> assembled event
turn = session.prepare_turn(
input=[UserMessage(content="Summarize my last order.")],
)
for data in turn.execute(stream=True):
message = data.event
if is_event_delta(message):
base = events.get(message.id)
if base is not None:
merge_event_delta(base, message)
else:
events[message.id] = message
import {
AgentSessionClient,
isEventDelta,
mergeEventDelta,
type TurnEvent,
} from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.createSession({ agentName: "support-bot" });
const events = new Map<string, TurnEvent>(); // event id -> assembled event
const turn = session.prepareTurn({
input: [{ type: "user.message", content: "Summarize my last order." }],
});
for await (const data of turn.execute({ stream: true })) {
const message = data.event;
if (isEventDelta(message)) {
const base = events.get(message.id);
if (base) mergeEventDelta(base, message);
} else {
events.set(message.id, message);
}
}
Resume streaming
When you lose the original execute(stream=True) stream (for example, after a page reload), call client.get_session() and session.get_turn(), then check turn.state. If it is still running, reconnect with turn.stream(after_sequence_number=...) and keep merging; if it has already finished, rebuild the index from turn.list_events() instead.
When resuming a running turn, pass after_sequence_number to continue after a known point; the stream closes when the turn reaches a terminal state. Either way, merge into the same id-keyed index of assembled events, so base events and their deltas keep merging seamlessly across the reconnect.
In Python, track last_sequence_number from each event’s sequence_number field while streaming (use data.sequence_number when present). The stream wrapper may leave TurnStreamData.sequence_number as None.
from truefoundry_gateway_sdk.agents import (
AgentSessionClient,
TurnStateRunning,
is_event_delta,
merge_event_delta,
)
# `client`, `session_id`, `turn_id`, `events`, and `last_sequence_number` already exist from the original stream:
# session_id: str - the id of the session
# turn_id: str - the id of the turn you were streaming
# events: dict[str, TurnEvent] - the id-keyed index of assembled events
# last_sequence_number: int - the sequence_number of the last event you processed
# Persist all four (for example, in your session store) so you can resume after a restart.
session = client.get_session(session_id)
turn = session.get_turn(turn_id)
turn_state = turn.state
if isinstance(turn_state, TurnStateRunning):
# Still running: reconnect to the live stream. after_sequence_number skips events
# you've already processed.
for data in turn.stream(after_sequence_number=last_sequence_number):
event = data.event
if getattr(event, "sequence_number", None) is not None:
last_sequence_number = event.sequence_number
elif data.sequence_number is not None:
last_sequence_number = data.sequence_number
message = event
if is_event_delta(message):
base = events.get(message.id)
if base is not None:
merge_event_delta(base, message)
else:
events[message.id] = message
else:
# Already finished: rebuild the index from the stored event log, which is
# authoritative and overwrites any partial state from the lost stream.
# list_events() returns already-merged events, so there are no deltas to merge here.
for event in turn.list_events():
events[event.id] = event
import { AgentSessionClient, isEventDelta, mergeEventDelta, type TurnEvent } from "truefoundry-gateway-sdk/agents";
// `client`, `sessionId`, `turnId`, `events`, and `lastSequenceNumber` already exist from the original stream:
// sessionId: string - the id of the Session
// turnId: string - the id of the Turn you were streaming
// events: Map<string, TurnEvent> - the id-keyed index of assembled events
// lastSequenceNumber: number - the sequenceNumber of the last event you processed
// Persist all four (for example, in your session store) so you can resume after a restart.
const session = await client.getSession({ sessionId });
const turn = await session.getTurn({ turnId });
const turnState = turn.state;
if (turnState.status === "running") {
// Still running: reconnect to the live stream. afterSequenceNumber skips events
// you've already processed.
for await (const data of turn.stream({ afterSequenceNumber: lastSequenceNumber })) {
lastSequenceNumber = data.sequenceNumber;
const message = data.event;
if (isEventDelta(message)) {
const base = events.get(message.id);
if (base) mergeEventDelta(base, message);
} else {
events.set(message.id, message);
}
}
} else {
// Already finished: rebuild the index from the stored event log, which is
// authoritative and overwrites any partial state from the lost stream.
// listEvents() returns already-merged events, so there are no deltas to merge here.
for await (const event of await turn.listEvents()) {
events.set(event.id, event);
}
}
Handle threads
A single Turn stream interleaves events from the root agent and any sub-agents that run in parallel. Every event carries a thread_id:
See Turn Events for the full reference.
The sequence below shows a turn where the root agent spawns a research sub-agent. Events interleave across two threads (main and the sub-agent), and the run pauses for an approval before a second turn resumes it. Solid arrows are turn inputs you send; dashed arrows are SSE events streamed back to your client.
import os
from collections import defaultdict
from truefoundry_gateway_sdk.agents import (
AgentSessionClient,
ModelMessageDeltaEvent,
ModelMessageEvent,
ThreadCreatedEvent,
ThreadDoneEvent,
TurnEvent,
is_event_delta,
merge_event_delta,
)
from truefoundry_gateway_sdk.types import TrueFoundrySystemToolInfo, UserMessage
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.create_session(agent_name="support-bot")
threads: dict[str, dict[str, TurnEvent]] = defaultdict(dict)
turn = session.prepare_turn(
input=[UserMessage(content="Summarize my last order.")],
)
for data in turn.execute(stream=True):
message = data.event
if message.thread_id is None:
print("turn level event:", message)
continue
if isinstance(message, (ModelMessageEvent, ModelMessageDeltaEvent)):
for tool_call in message.tool_calls or []:
if (
isinstance(tool_call.tool_info, TrueFoundrySystemToolInfo)
and tool_call.tool_info.name == "create_sub_agent"
):
print("sub agent is getting initialized")
if isinstance(message, ThreadCreatedEvent):
print(f"{message.thread_id} created: {message.title}")
if is_event_delta(message):
thread_events = threads.get(message.thread_id)
if thread_events is not None:
base = thread_events.get(message.id)
if base is not None:
merge_event_delta(base, message)
else:
threads[message.thread_id][message.id] = message
if isinstance(message, ThreadDoneEvent):
events_for_thread = threads.pop(message.thread_id)
print(f"{message.thread_id} done: {message.title} ({len(events_for_thread)} events)")
import {
AgentSessionClient,
isEventDelta,
mergeEventDelta,
type TurnEvent,
} from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.createSession({ agentName: "support-bot" });
const threads = new Map<string, Map<string, TurnEvent>>();
const turn = session.prepareTurn({
input: [{ type: "user.message", content: "Summarize my last order." }],
});
for await (const data of turn.execute({ stream: true })) {
const message = data.event;
if (message.threadId == null) {
console.log("turn level event:", message);
continue;
}
if (message.type === "model.message" || message.type === "model.message.delta") {
for (const toolCall of message.toolCalls ?? []) {
if (
toolCall.toolInfo?.type === "truefoundry-system" &&
toolCall.toolInfo.name === "create_sub_agent"
) {
console.log("sub agent is getting initialized");
}
}
}
if (message.type === "thread.created") {
console.log(`${message.threadId} created: ${message.title}`);
}
if (isEventDelta(message)) {
const base = threads.get(message.threadId)?.get(message.id);
if (base) mergeEventDelta(base, message);
} else {
let bucket = threads.get(message.threadId);
if (!bucket) {
bucket = new Map();
threads.set(message.threadId, bucket);
}
bucket.set(message.id, message);
}
if (message.type === "thread.done") {
const eventsForThread = threads.get(message.threadId) ?? new Map();
threads.delete(message.threadId);
console.log(`${message.threadId} done: ${message.title} (${eventsForThread.size} events)`);
}
}
Listing Events
Fetch the full event log of a finished turn with turn.list_events(). It yields the turn’s events in order and auto-paginates. Pass order="asc" (default, oldest-first) or order="desc" to control the direction.
Unlike turn.execute(stream=True) and turn.stream(), turn.list_events() returns already-merged events: each model message arrives as a single assembled ModelMessageEvent, never as a base plus deltas. There is nothing to merge — you can use each event directly.
For example, a model message that stream() delivers as a base event followed by deltas:
// On stream(): a base event plus its deltas, all sharing one id
{ "type": "model.message", "id": "0f3a...", "content": "" }
{ "type": "model.message.delta", "id": "0f3a...", "content": "Hel" }
{ "type": "model.message.delta", "id": "0f3a...", "content": "lo!", "finish_reason": "stop" }
is returned by turn.list_events() as one fully-merged event:
// On list_events(): the same message, already assembled
{ "type": "model.message", "id": "0f3a...", "content": "Hello!", "finish_reason": "stop" }
turn.list_events() is only available for turns that have completed. A running turn has no stored event log yet — use turn.execute(stream=True) or turn.stream() for live delivery instead.
import os
from truefoundry_gateway_sdk.agents import AgentSessionClient, TurnStateRunning
client = AgentSessionClient(
base_url=os.environ["TFY_GATEWAY_URL"],
api_key=os.environ["TFY_API_KEY"],
)
session = client.get_session(session_id="sess-abc123")
# Find the latest completed turn (newest-first).
for turn in session.list_turns():
# list_events() is only available once the turn has completed.
if isinstance(turn.state, TurnStateRunning):
continue
# order="asc" (default) yields oldest-first; use "desc" for newest-first.
for event in turn.list_events(order="asc"):
print(event)
break
import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
const client = new AgentSessionClient({
apiKey: process.env.TFY_API_KEY!,
baseUrl: process.env.TFY_GATEWAY_URL!,
});
const session = await client.getSession({ sessionId: "sess-abc123" });
// Find the latest completed turn (newest-first).
for await (const turn of await session.listTurns()) {
const turnState = turn.state;
// listEvents() is only available once the turn has completed.
if (turnState.status === "running") {
continue;
}
// order "asc" (default) yields oldest-first; use "desc" for newest-first.
for await (const event of await turn.listEvents({ order: "asc" })) {
console.log(event);
}
break;
}
Complete example
The Complete example is a runnable terminal chat client that implements every pattern in this guide — streaming, delta merging, approvals, questions, MCP auth, sub-agent threads, and multi-turn chaining.