Droid TypeScript SDK
Use the Factory Droid SDK for TypeScript to build custom agents, workflows, and product integrations.
The Droid SDK lets you run the same agent harness that powers Factory's CLI, desktop application, and web platform from your own code. It manages conversation context, tool execution, permissions, streaming, model selection, and multi-step execution.
Your application decides when Droid runs, which tools and models it can use, and what happens to the result.
Overview
The TypeScript SDK can start Droid as a Node.js subprocess, connect to an existing daemon, or call the Factory REST API. It provides one-shot runs, persistent sessions, streaming, model and session discovery, in-process MCP tools, and a typed client for every REST operation.
Choose an API
| Goal | API |
|---|---|
| Run one prompt and get the final result | run() |
| Keep context across prompts | createSession() |
| Continue a saved session | resumeSession() |
| Discover selectable models | listModels() |
| Find saved sessions | listSessions() |
| Hand a large goal to a team of agents on a daemon | droid.unstable.workstreams.start() |
| Start a workstream on a Droid Computer or environment | createWorkstream() |
| Call a Factory REST API operation | @factory/droid-sdk/api |
Start with run() for one prompt. Use a session when later prompts need the
same conversation history.
What you can build
Invoke Droid when a pull request opens, a support ticket is escalated, an incident is created, or scheduled repository maintenance is due. The agent does not need a chat interface.
Install and authenticate
Install the public npm package:
npm install @factory/droid-sdkIf your code imports Zod for structured output or SDK MCP tools, use Zod 3:
npm install zod@^3.24.0Import Node.js APIs from @factory/droid-sdk/node. Browser and daemon clients
are available from the root entrypoint, and the REST API client from
@factory/droid-sdk/api. This guide covers the high-level public API; the
exported TypeScript declarations define the complete compile-time API.
The SDK requires Node.js 18 or later.
By default, the SDK starts the droid CLI from PATH. Pass execPath to use a
different droid executable.
Set an API key:
export FACTORY_API_KEY="your-key"The SDK reads FACTORY_API_KEY from the process environment by default. You
can also pass apiKey to run(), createSession(), or resumeSession().
Quick start
Run one prompt
import { run } from '@factory/droid-sdk/node';
const result = await run('Summarize this repository.');
if (!result.success) {
throw new Error(result.error?.message ?? `Run failed: ${result.subtype}`);
}
console.log(result.text);run() creates a session, consumes its complete message stream, returns the
terminal DroidResult, and closes the session.
Continue a conversation
import { createSession, DroidMessageType } from '@factory/droid-sdk/node';
const session = await createSession();
try {
for await (const message of session.stream('What does this project do?')) {
if (message.type === DroidMessageType.Assistant) {
console.log(message.text);
}
}
for await (const message of session.stream('What should I test first?')) {
if (message.type === DroidMessageType.Assistant) {
console.log(message.text);
}
}
} finally {
await session.close();
}In the above example, the second prompt uses context from the first turn. Remember to close sessions that your code creates.
Core concepts
Session
A session holds conversation history, settings, and a working directory. Create
one with createSession() or load a saved session with resumeSession().
Turn
A turn starts when you send one prompt with session.stream(). A session can
run one turn at a time.
Stream message
session.stream() returns an async iterable of complete messages. Narrow each
message by its type. Messages can report user input, assistant output, tool
activity, hooks, errors, and a terminal DroidResult.
Result
A normally completed turn ends with a DroidResult. run() consumes the stream and returns that same result type. See Streaming and results for result handling, errors, token usage, and cancellation.
Ownership and cleanup
Cleanup depends on how the session was created.
| API | Cleanup |
|---|---|
run() | Closes its session automatically |
createSession() | Call await session.close() |
resumeSession() | Call await session.close() |
Use finally blocks so cleanup also runs after an error.
Sessions
Use a session when prompts need shared conversation history. A session owns its working directory, settings, and one active turn at a time.
Create and use a session
import { createSession, DroidMessageType } from '@factory/droid-sdk/node';
const session = await createSession({
cwd: process.cwd(),
});
async function send(prompt: string) {
for await (const message of session.stream(prompt)) {
if (message.type === DroidMessageType.Assistant) {
console.log(message.text);
}
}
}
try {
await send('What does this project do?');
await send('What should I test first?');
} finally {
await session.close();
}The second turn uses context from the first. cwd defaults to process.cwd(). Model and reasoning defaults come from Droid settings.
Customize the system prompt
Set systemPrompt when creating a session. A string replaces Droid's default
behavioral prompt:
const session = await createSession({
systemPrompt: 'Act as a focused dependency-analysis agent.',
});To keep Droid's default prompt and append instructions:
const session = await createSession({
systemPrompt: {
type: 'preset',
preset: 'droid',
append: 'Prioritize security findings and cite relevant files.',
},
});systemPrompt is available with run(), createSession(), and
droid.sessions.create(). It is set at session creation and preserved when the
session is resumed or forked.
Resume a saved session
import { DroidMessageType, resumeSession } from '@factory/droid-sdk/node';
async function continueSession(sessionId: string) {
const session = await resumeSession(sessionId);
try {
for await (const message of session.stream(
'Continue from the last conversation.'
)) {
if (message.type === DroidMessageType.Assistant) {
console.log(message.text);
}
}
} finally {
await session.close();
}
}resumeSession() restores the saved conversation, working directory, and session settings. It does not accept cwd, modelId, or reasoning options.
Resume options can provide new permission and AskUser handlers, disabled tools, MCP servers, an abort signal, and observability sinks.
Read session state
Each handle exposes its ID, current working directory, and current settings.
console.log(session.id);
console.log(session.cwd);
console.log(session.settings.modelId);
console.log(session.settings.reasoningEffort);
console.log(session.settings.interactionMode);settings is read-only. The SDK updates settings and cwd when Droid reports a change.
Update session settings
Use updateSettings() for changes that should apply to later turns in the current session.
import { AutonomyLevel, ReasoningEffort } from '@factory/droid-sdk/node';
await session.updateSettings({
modelId: 'model-id',
reasoningEffort: ReasoningEffort.High,
autonomyLevel: AutonomyLevel.Low,
disabledToolIds: ['Execute'],
});Updatable settings include:
- model and reasoning effort
- interaction mode and autonomy level
- spec-mode model settings
- tool availability overrides
Interaction mode controls whether Droid operates normally in Auto mode or produces a read-only plan in Spec mode. Autonomy level controls which actions require approval while Droid is in Auto mode. Tool availability overrides can enable, disable, or restrict the tools available to the session.
Use enterSpecMode() to enter Spec mode. Return to Auto mode with
updateSettings({ interactionMode: DroidInteractionMode.Auto }).
The updated values are available through session.settings.
Rename a session
Use rename() after the first turn to replace the generated title.
await session.rename({ title: 'Authentication review' });List saved sessions
listSessions() reads local Droid session storage. It does not start Droid or
call the Factory API.
List sessions for the current working directory:
import { listSessions } from '@factory/droid-sdk/node';
const sessions = await listSessions({ limit: 10 });
for (const session of sessions) {
console.log(session.id, session.title, session.modifiedTime);
}List sessions across all working directories:
const sessions = await listSessions({
fetchOutsideCWD: true,
limit: 10,
});Results are sorted by modifiedTime, newest first. Each item includes its ID, title, owner, message count, creation time, modification time, and working directory when available.
What persists
Droid saves the session data needed to continue later.
| Restored from the saved session | Attach again when resuming |
|---|---|
| Conversation history | Permission handler |
| Working directory | AskUser handler |
| Title | SDK MCP servers |
| Session settings | Observability sinks |
| Abort signal |
Session settings include model, reasoning, Auto or Spec interaction mode, autonomy, spec-mode model settings, tool availability overrides, and the custom system prompt.
Handlers, observability sinks, abort signals, and SDK MCP servers are runtime objects. Droid cannot serialize them with the session, so attach them again when calling resumeSession(). They do not need to be the same object instances or implementations used when the session was created.
Close sessions safely
Call close() in finally when your code creates or resumes a session.
Closing releases the subprocess, subscriptions, and SDK-owned MCP servers.
const session = await createSession();
try {
// Use the session.
} finally {
await session.close();
}Streaming and results
Each call to session.stream() starts one turn and returns an async iterable.
By default, it yields complete messages and ends with a terminal DroidResult.
Complete messages
for await (const message of session.stream('Find the failing test.')) {
switch (message.type) {
case DroidMessageType.Assistant:
console.log(message.text);
break;
case DroidMessageType.ToolCall:
console.log(`Tool: ${message.name}`);
break;
case DroidMessageType.Result:
console.log(message.subtype);
break;
}
}Default message types:
| Type | Purpose |
|---|---|
assistant | Complete assistant message |
user | User message recorded by the session |
tool_call | Complete tool request |
tool_result | Complete tool result |
hook | Hook execution |
error | Runtime error event |
result | Terminal DroidResult |
Partial events
Enable partial events only when the application needs live text, thinking, tool progress, token usage, or state updates.
for await (const event of session.stream('Explain the test failure.', {
includePartialMessages: true,
})) {
if (event.type === DroidMessageType.AssistantTextDelta) {
process.stdout.write(event.text);
}
}Partial streams can also include thinking deltas, tool-call deltas, tool progress, token updates, permission results, settings changes, working-state changes, and MCP status.
Handle the result
DroidResult is a discriminated union. Check subtype or success before using the response.
const result = await run('Run the test suite.');
switch (result.subtype) {
case 'success':
console.log(result.text);
break;
case 'interrupted':
console.log('The run was interrupted.');
break;
case 'error_during_execution':
case 'error_structured_output':
console.error(
result.structuredOutputError?.message ??
result.error?.message ??
'Unknown error'
);
break;
}| Subtype | Meaning |
|---|---|
success | The turn completed successfully |
interrupted | The turn was cancelled or permission declined |
error_during_execution | Droid could not complete the requested work |
error_structured_output | Structured-output generation or validation failed |
Every result includes:
console.log(result.sessionId);
console.log(result.durationMs);
console.log(result.turnCount);
console.log(result.messages);
console.log(result.text);
console.log(result.tokenUsage);run() returns the terminal result directly. When consuming session.stream(), handle the message whose type is DroidMessageType.Result.
Token and context usage
DroidResult.tokenUsage reports raw token counts for the current SDK turn and,
when available, the Factory Service Credits (FSC) charged for that turn. One
turn is one run() or session.stream() call. This is not the session's
lifetime total.
if (result.tokenUsage) {
console.log(`input: ${result.tokenUsage.inputTokens}`);
console.log(`output: ${result.tokenUsage.outputTokens}`);
console.log(`cache read: ${result.tokenUsage.cacheReadTokens}`);
console.log(`cache created: ${result.tokenUsage.cacheCreationTokens}`);
if (result.tokenUsage.factoryCredits !== undefined) {
console.log(`Factory Service Credits: ${result.tokenUsage.factoryCredits}`);
}
}| Field | What it measures during the turn |
|---|---|
inputTokens | Provider-reported input across model calls, which can include instructions, tools, conversation history, and the latest message |
outputTokens | Output generated across model calls, not only the final visible response |
cacheReadTokens | Input tokens reused from the prompt cache |
cacheCreationTokens | Input tokens written to the prompt cache |
thinkingTokens | Reasoning tokens reported separately when available |
factoryCredits | Factory Service Credits charged, when available |
One turn can make several model calls while Droid uses tools. Their usage is combined in the result, along with usage from delegated work. Earlier conversation can be counted again when it is included as input to a later turn. Cached input is also reported through the separate cache fields. tokenUsage is null when usage is unavailable.
Partial streams can emit DroidMessageType.TokenUsageUpdate. Unlike the per-turn result, these events contain cumulative committed usage for the session.
getContextStats() measures current context occupancy, not cumulative token usage:
const stats = await session.getContextStats();
console.log(stats.used, stats.remaining, stats.limit, stats.accuracy);| Field | Meaning |
|---|---|
used | Estimated tokens currently occupied by prompts, tools, instructions, and conversation history |
limit | Active model's maximum input window; unresolved model IDs fall back to the effective compaction limit |
remaining | Math.max(0, limit - used) |
accuracy | Currently estimated, because Droid approximates tokens from character counts |
remaining estimates room in the model's input window. It does not
necessarily mean tokens remaining before compaction. Droid can compact at a
lower configured threshold, commonly 250,000 tokens, even when the active
model's input window is larger.
Errors
An error event and a thrown exception mean different things:
- An
errormessage reports a runtime problem during the turn. A terminalDroidResultnormally follows and describes the final outcome. - A failed
DroidResultmeans the stream completed, but the requested work or structured output failed. - A thrown exception means the SDK operation itself could not finish, such as an abort, connection failure, protocol error, or concurrent stream attempt.
Handle stream exceptions around the iteration:
try {
for await (const message of session.stream('Run the tests.')) {
if (message.type === DroidMessageType.Error) {
console.error(message.message);
}
}
} catch (error) {
console.error('The stream could not finish:', error);
}run() follows the same distinction: agent failures are returned as failed results, while setup, transport, protocol, and abort failures are thrown.
Stream concurrency
One session handle can have one active stream. Starting another active stream
throws ConcurrentStreamError.
Cancellation and interruption
Use an abort signal to cancel one turn:
const controller = new AbortController();
setTimeout(
() => controller.abort(new Error('Timed out after 5 seconds')),
5_000
);
for await (const message of session.stream('Perform a long review.', {
abortSignal: controller.signal,
})) {
// Handle messages.
}The stream interrupts the active turn and throws the abort reason.
Use session.interrupt() when another part of the application needs to stop
the active turn:
await session.interrupt();The stream normally continues to a terminal result with subtype interrupted.
The session remains available for later prompts.
Breaking out of the stream also interrupts the active turn:
for await (const message of session.stream('Investigate every failing test.')) {
if (message.type === DroidMessageType.Assistant) {
console.log(message.text);
// Interrupt this turn instead of merely hiding its remaining output.
break;
}
}Breaking out of the loop before the turn finishes sends an interrupt request
to Droid. This cancels the remaining model and tool work for that turn. The
session remains open and can accept another prompt, but this loop does not
receive the interrupted turn's terminal DroidResult.
A session-level abort signal passed to createSession() closes the entire
session when aborted.
Daemon clients can also stop a session they have not attached to, such as a
workstream's agent, with droid.sessions.interrupt(sessionId). The session must
be open in the daemon.
Models
Model IDs are strings because availability depends on the account,
organization policy, and daemon configuration. Omit modelId to use the Droid
default.
Discover available models
Use listModels() to inspect the models available to the current account and
project before creating a session.
import { listModels } from '@factory/droid-sdk/node';
const models = await listModels();
for (const model of models) {
console.log(model.id, model.defaultReasoningEffort);
}listModels() starts a one-shot Droid process, requests the model catalog
without creating a session, and closes the process before returning.
Disabled models are hidden by default. Include them when building a model picker or explaining why a model cannot be selected:
const models = await listModels({ includeDisabled: true });
for (const model of models) {
const status = model.disabled
? `disabled: ${model.disabledReason}`
: 'available';
console.log(model.displayName, status);
}Applications connected to a Droid daemon can request the same catalog:
const models = await droid.models.list({
includeDisabled: true,
});Both APIs return ModelInfo[]. The catalog includes built-in and configured
custom models after applying feature flags, region availability, and
organization policy. Custom endpoint and credential configuration is never
returned.
| Field | Meaning |
|---|---|
id | Value accepted by modelId |
displayName | Full display name |
shortDisplayName | Compact display name |
modelProvider | Model provider |
supportedReasoningEfforts | Reasoning levels accepted by the model |
defaultReasoningEffort | Reasoning level used when none is supplied |
isCustom | Whether the model is user-configured |
disabled | Whether policy prevents selecting the model |
disabledReason | Explanation when disabled is true |
noImageSupport | Whether image input is unavailable |
supportsImageGeneration | Whether the model can generate images |
Optional fields provide the model tier, token multiplier, classification, and display badges.
Select a model
Pass a configured model ID when creating a session or running one prompt.
import {
createSession,
DroidMessageType,
ReasoningEffort,
} from '@factory/droid-sdk/node';
const session = await createSession({
modelId: 'model-id',
reasoningEffort: ReasoningEffort.High,
});
try {
for await (const message of session.stream('Review this repository.')) {
if (message.type === DroidMessageType.Assistant) {
console.log(message.text);
}
}
} finally {
await session.close();
}For an existing session, call updateSettings():
await session.updateSettings({
modelId: 'model-id',
reasoningEffort: ReasoningEffort.High,
});Set reasoningEffort only to a level supported by the selected model. Omit it
to use the model's configured default.
Use the Factory Router
Set modelId to "auto" to let Factory choose a model for each turn.
import { run } from '@factory/droid-sdk/node';
const result = await run('Find the cause of the failing tests.', {
modelId: 'auto',
});
if (!result.success) {
throw new Error(result.error?.message ?? `Run failed: ${result.subtype}`);
}
console.log(result.text);The selected model can change between turns. Choose a fixed model ID when runs must use the same model.
Configure mode-specific models
The primary model handles normal turns. The spec-mode model handles turns in spec mode.
import {
createSession,
DroidInteractionMode,
ReasoningEffort,
} from '@factory/droid-sdk/node';
const session = await createSession({
modelId: 'auto',
interactionMode: DroidInteractionMode.Spec,
specModeModelId: 'model-id',
specModeReasoningEffort: ReasoningEffort.High,
});
try {
for await (const _message of session.stream(
'Draft an implementation plan.'
)) {
// Consume messages.
}
} finally {
await session.close();
}You can also choose the model when entering spec mode:
await session.enterSpecMode({
specModeModelId: 'model-id',
specModeReasoningEffort: ReasoningEffort.High,
});Use a custom model
Configure custom models in ~/.factory/settings.json. See Model Independence for provider, credential, and model settings.
After configuration, pass the custom model ID anywhere the SDK accepts modelId. The ID uses custom: followed by the configured model value.
import { run } from '@factory/droid-sdk/node';
const result = await run('Summarize this repository.', {
modelId: 'custom:gpt-4o-mini',
});
if (!result.success) {
throw new Error(result.error?.message ?? `Run failed: ${result.subtype}`);
}
console.log(result.text);Inputs and outputs
Pass images, documents, or an output schema in the options for the turn that should use them. Both run() and session.stream() accept these options.
Images and documents
const result = await run('Describe this image.', {
images: [
{
type: 'base64',
data: base64Png,
mediaType: 'image/png',
},
],
});data contains the base64-encoded image bytes. Supported media types are image/jpeg, image/png, image/gif, and image/webp.
Documents
const result = await run('Summarize this document.', {
files: [
{
type: 'text',
mediaType: 'text/plain',
data: report,
name: 'report.txt',
},
],
});Document inputs support:
| Source | type | mediaType | data |
|---|---|---|---|
| Plain text | text | text/plain | The document text |
| PDF document | base64 | application/pdf | Base64-encoded PDF file bytes |
name is optional for both forms. Attach content through data; a filesystem path by itself is not a document upload.
Structured output
Use outputFormat when another system needs a JSON object that follows a schema.
import { OutputFormatType, run } from '@factory/droid-sdk/node';
const result = await run('Return the repository name and test command.', {
outputFormat: {
type: OutputFormatType.JsonSchema,
schema: {
type: 'object',
properties: {
repository: { type: 'string' },
testCommand: { type: 'string' },
},
required: ['repository', 'testCommand'],
additionalProperties: false,
},
},
});
if (!result.success) {
throw new Error(
result.structuredOutputError?.message ??
result.error?.message ??
`Structured output failed (${result.subtype}).`
);
}
if (!result.structuredOutput) {
throw new Error('No structured output was returned.');
}
console.log(JSON.stringify(result.structuredOutput, null, 2));Invalid or missing schema output normally produces a failed result with subtype error_structured_output; structuredOutputError contains the available validation details.
structuredOutput is typed as unknown, because a JSON Schema does not provide a TypeScript runtime validator. Check that it is present and validate or narrow its shape before reading fields or passing it to another system. A successful result can still omit structured output, so do not use result.success as the only check.
Permissions and user input
Autonomy
Autonomy controls which actions require approval in Auto mode.
import { AutonomyLevel } from '@factory/droid-sdk/node';
const session = await createSession({
autonomyLevel: AutonomyLevel.Off,
});| Level | Behavior |
|---|---|
Off | Ask before every action |
Low | Allow file edits and read-only commands |
Medium | Allow reversible commands |
High | Allow commands without approval prompts |
When omitted, Droid uses the configured default. Autonomy does not select Auto or Spec mode; interactionMode does that.
Permission handler
Use permissionHandler to approve or reject requests that reach the client.
The handler may be synchronous or asynchronous.
import {
ToolConfirmationOutcome,
ToolConfirmationType,
} from '@factory/droid-sdk/node';
const session = await createSession({
autonomyLevel: AutonomyLevel.Off,
permissionHandler({ toolUses, options }) {
const createsOnly =
toolUses.length > 0 &&
toolUses.every((use) => use.details.type === ToolConfirmationType.Create);
const desired = createsOnly
? ToolConfirmationOutcome.ProceedOnce
: ToolConfirmationOutcome.Cancel;
const offered = options.find((option) => option.value === desired);
if (!offered) throw new Error(`Outcome not offered: ${desired}`);
return desired;
},
});toolUses describes the pending actions. options contains the outcomes available for this request. Return one of those values. Common outcomes are:
| Outcome | Effect |
|---|---|
ProceedOnce | Approve this request |
ProceedAlways | Approve and persist the applicable rule |
Cancel | Reject the request |
The available outcomes vary by request. Returning an unavailable value, throwing from the handler, or omitting the handler cancels the request.
AskUser handler
askUserHandler answers questions Droid asks during a turn. Connect it to a form, prompt, or other application UI.
const result = await run('Ask me which environment to deploy.', {
askUserHandler({ questions }) {
return {
answers: questions.map((question) => ({
index: question.index,
question: question.question,
answer: question.options[0] ?? 'none',
})),
};
},
});Each answer must copy the question's index and question. To decline the questionnaire:
return { cancelled: true, answers: [] };Without a handler, AskUser requests are declined.
Both handlers also receive sessionId, the ID of the session that asked; the
answer goes back to that session. On a daemon, a session's permission handler
also answers permission requests from subagents that the
session starts, so sessionId can be a subagent's ID rather than the session's
own.
Tool controls
Disable tools when creating, resuming, or updating a session:
const session = await createSession({
disabledToolIds: ['Execute'],
});
await session.updateSettings({
disabledToolIds: ['Execute', 'Edit'],
});Tool IDs are strings. Use listTools() to inspect the tools available to the current session.
const tools = await session.listTools();
for (const tool of tools) {
console.log(tool.id, tool.allowed);
}Discover tool schemas
Set includeSchemas to include JSON Schemas for tool inputs, results, and
progress updates. Use toolIds to return only the tools you need.
const tools = await session.listTools({
includeSchemas: true,
toolIds: ['Read', 'Edit', 'Execute'],
});
for (const tool of tools) {
console.log(tool.id, tool.source);
console.log('input:', tool.schemas.input);
console.log('result:', tool.schemas.result?.content);
console.log('parsed result:', tool.schemas.result?.parsedContent);
console.log('progress:', tool.schemas.progress);
}| Field | Meaning |
|---|---|
source | Broad tool origin: native, mcp, or connector; use id for identity |
schemas.input | JSON Schema for arguments accepted by the tool |
schemas.result.content | JSON Schema for content returned when the tool finishes |
schemas.result.parsedContent | JSON Schema for parsed result content, when declared |
schemas.progress | JSON Schema for structured updates emitted while the tool runs |
With includeSchemas: true, schemas and schemas.input are guaranteed on
every returned tool. source, result, parsedContent, and progress
remain optional because tools only include the metadata they declare. An older
Droid that omits requested schemas throws ProtocolError with instructions to
update Droid.
The public SDK provides subtractive disabledToolIds. It does not expose a
restrictive allowlist.
Extensions
Use the extension that matches the job:
| Extension | Use it for |
|---|---|
| Skills | Reusable instructions and supporting files |
| SDK MCP tools | In-process TypeScript functions exposed to Droid |
| External MCP servers | Tools provided by another process or service |
| Hooks | Commands that run at defined lifecycle events |
Skills
List the skills available to the session:
const { skills } = await session.listSkills();
for (const skill of skills) {
console.log(skill.name, skill.enabled);
}Enable or disable a skill at the user or project level:
import { SettingsLevel } from '@factory/droid-sdk/node';
await session.setSkillDisabled({
skillName: 'skill-name',
disabled: true,
settingsLevel: SettingsLevel.Project,
});Skills can come from project, personal, built-in, or automation settings.
In-process MCP tools
Use tool() to define an in-process TypeScript function with a Zod input schema.
The SDK and Droid CLI must use compatible Factory protocol versions.
import { z } from 'zod';
import {
createSdkMcpServer,
createSession,
tool,
} from '@factory/droid-sdk/node';
const server = createSdkMcpServer({
name: 'review-tools',
tools: [
tool(
'lookup_owner',
'Returns the owner of a file',
{ path: z.string() },
({ path }) => `Owner for ${path}: platform-team`
),
],
});
const session = await createSession({
mcpServers: [server],
});
try {
for await (const _message of session.stream(
'Find the owner of src/index.ts.'
)) {
// Consume messages.
}
} finally {
await session.close();
}The SDK starts an authenticated loopback MCP server and closes it with the session. Tool calls still follow the session's autonomy and permission rules. Attach SDK MCP servers again when resuming a saved session because they are runtime objects, not persisted session data.
External MCP servers
Pass external MCP configuration when creating or resuming a session:
const session = await createSession({
mcpServers: [
{
name: 'docs',
type: 'http',
url: 'https://example.com/mcp',
headers: [],
},
],
});Supported session configuration:
| Transport | type field | Required configuration |
|---|---|---|
| Local process | Omit | command and args |
| HTTP | http | url and headers |
| SSE | sse | url and headers |
Inspect server status and discovered tools:
try {
const { servers, summary } = await session.listMcpServers();
const tools = await session.listMcpTools();
console.log(servers, summary, tools);
} finally {
await session.close();
}addMcpServer(), removeMcpServer(), and toggleMcpServer() change the user's Droid configuration.
Use authenticateMcpServer({ serverName }) when a server requires authentication.
Hooks
Hooks run shell commands at defined lifecycle events.
Configure them in .factory/hooks.json:
{
"PreToolUse": [
{
"matcher": "Execute",
"hooks": [
{
"type": "command",
"command": "echo before Execute"
}
]
}
]
}Hooks can run before or after tools, when prompts are submitted, when notifications arrive, during compaction, and when sessions or subagents stop.
Hook events are available in the stream:
for await (const message of session.stream('Run the tests.')) {
if (message.type === DroidMessageType.Hook) {
console.log(message.status, message.command);
}
}The SDK exposes hook schemas and stream events, but it does not provide a programmatic hook-registration API.
Session lifecycle
Fork, compact, and rewind return ready successor sessions and retire the source wrapper.
Fork
fork() creates a new session from the current conversation.
const forked = await session.fork();Compact
compact() summarizes older conversation history and returns the successor in session.
const { session: compacted, removedCount } = await forked.compact();Rewind
rewind() returns the conversation to an earlier message and can restore or delete files.
const {
session: rewound,
restoredCount,
deletedCount,
} = await compacted.rewind({
messageId,
filesToRestore,
filesToDelete,
forkTitle: 'Before the failed change',
});Successor ownership
After a successful replacement, active operations on the source wrapper throw
SessionReplacedError. Its id, settings, and cwd remain readable, and
close() is a no-op. The persisted source session can still be loaded later
with resumeSession(sourceId).
Do not replace a session while it has an active stream.
Spec mode
Spec mode lets Droid inspect the codebase and propose a plan without changing files. Start a session in Spec mode or switch an existing session with enterSpecMode():
const session = await createSession({
interactionMode: DroidInteractionMode.Spec,
});
// Or switch an existing Auto session.
await session.enterSpecMode();enterSpecMode() can also set the model used for planning:
await session.enterSpecMode({
specModeModelId: 'model-id',
specModeReasoningEffort: ReasoningEffort.High,
});Leave without approving a plan
Call updateSettings() to return the session to Auto mode without approving a
plan or starting implementation:
import { DroidInteractionMode } from '@factory/droid-sdk/node';
// Return to Auto mode without approving or implementing the plan.
await session.updateSettings({
interactionMode: DroidInteractionMode.Auto,
});Changing the interaction mode does not select a ToolConfirmationOutcome or
approve an ExitSpecMode permission request.
updateSettings() is available on Node DroidSession handles returned by
createSession(), resumeSession(), and replacement operations such as
fork(), compact(), and rewind(). Daemon clients use
droid.sessions.updateSettings().
Approve a plan
When the plan is ready, Droid sends an ExitSpecMode permission request. Its
details contain the plan. Unlike changing the interaction mode with
updateSettings(), approving it with ProceedOnce accepts the completed plan,
returns to Auto mode, and continues implementation in the same session:
import {
AutonomyLevel,
createSession,
DroidInteractionMode,
DroidMessageType,
ToolConfirmationOutcome,
ToolConfirmationType,
} from '@factory/droid-sdk/node';
const session = await createSession({
interactionMode: DroidInteractionMode.Auto,
autonomyLevel: AutonomyLevel.Low,
permissionHandler({ toolUses }) {
const request = toolUses.find(
({ details }) => details.type === ToolConfirmationType.ExitSpecMode
);
if (request?.details.type === ToolConfirmationType.ExitSpecMode) {
console.log(request.details.plan);
return ToolConfirmationOutcome.ProceedOnce;
}
return ToolConfirmationOutcome.Cancel;
},
});
try {
await session.enterSpecMode();
for await (const message of session.stream('Plan and update README.md.')) {
if (message.type === DroidMessageType.Result) {
console.log(message.subtype);
}
}
} finally {
await session.close();
}The handler can be asynchronous. An interactive application should display the plan and wait for the user's decision before returning.
Return Cancel to reject the plan. The turn ends with an interrupted result, and the session remains available for another prompt.
Some ExitSpecMode requests offer ProceedNewSession variants. Return one of those offered values to hand implementation to a new session. Return only values present in the request's options.
Workstreams
A workstream hands a large goal to an orchestrator
session, which splits it across agent sessions that work in parallel. Daemon
clients start, list, and configure workstreams through
droid.unstable.workstreams. The daemon runs the orchestrator and its agents,
so a workstream keeps going after your client disconnects. To start one on a
Droid Computer or an environment without a daemon connection, use
the REST API.
The workstream APIs are experimental. They live under droid.unstable and can change in any SDK release. They need a daemon that supports workstreams and an account with access to Workstreams. On an older daemon, every call rejects with a ProtocolError.
Connect to a daemon
Run droid daemon on the machine that should host the orchestrator, then
connect to it. A local daemon listens on ws://127.0.0.1:37643.
import { connectToDaemon } from '@factory/droid-sdk';
const apiKey = process.env.FACTORY_API_KEY;
if (!apiKey) throw new Error('Set FACTORY_API_KEY.');
const droid = await connectToDaemon({
url: 'ws://127.0.0.1:37643',
auth: { apiKey },
});
try {
// Start, list, and configure workstreams.
} finally {
droid.disconnect();
}Start a workstream
start() creates the orchestrator session and returns its handle, already
attached. The orchestrator waits for its first prompt, so send the goal with
stream():
import {
AutonomyLevel,
DroidMessageType,
ReasoningEffort,
WorkstreamMachineTargetKind,
} from '@factory/droid-sdk';
// Agents on other machines reach this daemon through its relay.
await droid.relay.start();
const orchestrator = await droid.unstable.workstreams.start({
cwd: '/path/to/repo',
title: 'Migrate billing to the new payments API',
autonomyLevel: AutonomyLevel.High,
agentReasoningEffort: ReasoningEffort.High,
machines: [
{
label: 'build box',
description: 'Has the billing repo and its test database.',
target: {
kind: WorkstreamMachineTargetKind.Computer,
computerId: 'computer-id',
},
},
],
defaultMachineLabel: 'build box',
});
for await (const message of orchestrator.stream(
'Migrate the billing service to the new payments API.'
)) {
if (message.type === DroidMessageType.Result && !message.success) {
throw new Error(
message.error?.message ?? `Turn ended: ${message.subtype}`
);
}
}
// Detach, not close: the workstream keeps running in the daemon.
await orchestrator.detach();start() takes the options of droid.sessions.create() plus the workstream
settings below. It sets sessionId, interactionMode, and privacyLevel
itself, so every workstream starts as a new, private session. Agents inherit
the orchestrator's autonomyLevel; below High, they stop and ask for
permission (see Answer agent prompts).
detach() stops this client from receiving the orchestrator's events, and the
workstream keeps running in the daemon. close() ends the orchestrator
session, and with it the workstream.
| Setting | Meaning |
|---|---|
machines | Machines agents may run on besides the orchestrator's. Omit it to keep every agent there. |
defaultMachineLabel | Label of the machine agents use when the orchestrator names none. Must match a machines entry. |
agentModel | Model ID for every agent. Omit it to use the orchestrator's model. |
agentReasoningEffort | Reasoning effort for every agent. Omit it to use the orchestrator's. |
teamId | Organization team the workstream belongs to. |
symbol | Symbol the Factory App shows for the workstream. The app shows its default for a value it doesn't offer. |
When the orchestrator uses a custom model and agentModel is omitted, an agent
on another machine starts on that machine's default model instead.
Each entry in machines has a label, a target, and an optional
description. The orchestrator reads descriptions to decide which work to send
where, so describe the repositories, tools, and kind of work each machine is
set up for. See Machines for how the machine types
behave.
Target kind | Runs agents on |
|---|---|
Local | The computer running this daemon |
Computer | The Droid Computer named by computerId, shared by every agent sent there |
Template | A fresh cloud computer per agent from the environment named by templateId |
Agents on a Computer or Template target reach this daemon through its
relay, so call droid.relay.start() before start().
Labels are up to 40 letters, digits, spaces, _, ., or -, start with a
letter or digit, and must be unique ignoring case. The label orchestrator is
reserved for the orchestrator's own machine. Descriptions are up to 2,000
characters. start() rejects invalid settings before it creates a session.
Answer agent prompts
The orchestrator handle's permissionHandler and askUserHandler answer the
orchestrator's own requests. Its permissionHandler also answers permission
requests from subagents that the orchestrator starts with
the Task tool. Neither handler answers the workstream's agents. To answer an
agent, attach to it with its own handlers:
import { ToolConfirmationOutcome } from '@factory/droid-sdk';
const [workstream] = await droid.unstable.workstreams.list();
for (const agent of workstream?.agents ?? []) {
await droid.sessions.resume(agent.id, {
permissionHandler({ sessionId, toolUses }) {
console.log(`${sessionId} asks to run ${toolUses.length} tool(s)`);
return ToolConfirmationOutcome.Cancel;
},
askUserHandler() {
return { cancelled: true, answers: [] };
},
});
}The handlers answer only while this client stays connected and attached. An agent on another machine runs in that machine's daemon, so connect to that daemon to attach to it.
List workstreams
const workstreams = await droid.unstable.workstreams.list();
for (const workstream of workstreams) {
console.log(
workstream.title,
workstream.settings.agentModel,
`${workstream.agents.length} agent(s)`
);
}list() returns the workstreams this daemon has recorded, most recently
updated first. Each entry is the orchestrator's session summary plus its
settings and agents, the summaries of the agents this daemon ran for it.
Pass { includeArchived: true } to include archived workstreams.
- A workstream appears once its goal is sent. The daemon records a session only after it has a user-visible message.
- Agents that ran on other machines, and workstreams whose orchestrator runs on another daemon, are not listed.
- Entries carry no live working state. Use
droid.sessions.listOpened()to see which sessions are working now.
Change a running workstream's settings
// The start example detached its handle, so attach again by the
// orchestrator's ID: orchestrator.id, or the workstream's id from list().
const attached = await droid.sessions.resume(orchestratorId);
const settings = await droid.unstable.workstreams.updateSettings(attached, {
agentModel: 'model-id',
agentReasoningEffort: null,
});
await attached.detach();updateSettings() changes agentModel, agentReasoningEffort, teamId, and
symbol, and resolves with the workstream's settings after the change. A
null value clears a setting, and an omitted key keeps it. Machines are fixed
when the workstream starts.
The next agent the orchestrator starts uses the new model and reasoning effort. A running agent switches at the start of its next turn. Clearing them leaves running agents on what they have.
Pass the orchestrator's attached handle, from start() or
droid.sessions.resume(). A detached handle, or a session that is not a
workstream's orchestrator, rejects before anything is written. Only the
settings the patch names are written, so settings the workstream's runner or
another client changed in the meantime keep their values. A daemon older than
protocol 1.321.0 gets the SDK's whole tag list instead, so there a write from
another client at the same time is overwritten.
Stop an agent
await droid.sessions.interrupt(agentId);droid.sessions.interrupt() stops a session's current turn by ID, without
attaching to it. The session must be open in the daemon; otherwise the call
rejects.
Observability and advanced APIs
Observability
import { run } from '@factory/droid-sdk/node';
const result = await run('Check the repository status.', {
observability: {
logger: {
log(event) {
console.log(event.level, event.name, event.attributes);
},
},
},
});
if (!result.success) {
throw new Error(result.error?.message ?? `Run failed: ${result.subtype}`);
}Pass logger, metrics, or tracing sinks through observability.
Sink methods must not throw.
The SDK excludes prompts, messages, tool inputs, raw output, and stack traces from observability events.
Process startup emits telemetry before the first turn, and close() emits a request log.
To verify turn-specific logs, snapshot the count after creating the session and check it before closing.
Raw notifications
Use onNotification() only when session.stream() does not expose the event you need:
const unsubscribe = session.onNotification(
(notification) => console.log(notification.type),
{ type: 'settings_updated' }
);
unsubscribe();Factory REST API
@factory/droid-sdk/api exports a typed function for every operation in the
Factory REST API. It is generated from the public API spec,
runs in Node.js and in the browser, and is available in @factory/droid-sdk
0.11.0 or later. Each function is named after its operation ID in the spec
that https://api.factory.ai/api/v0/openapi.json serves.
TypeScript resolves the ./api and ./node subpaths only when
moduleResolution is node16, nodenext, or bundler. Under node10 (the
legacy node setting), it cannot find their type declarations.
Create a client
createFactoryClient is the only way to build a client. Every operation
requires its client option, so a call without one fails typecheck.
import * as factoryApi from '@factory/droid-sdk/api';
const client = factoryApi.createFactoryClient({
apiKey: process.env.FACTORY_API_KEY,
});| Option | Default | Notes |
|---|---|---|
apiKey | none | Sent as Authorization: Bearer <apiKey> on a request with bearer security. A function is called on each request that uses it. |
baseUrl | https://api.factory.ai | Origin without a trailing slash |
fetch | globalThis.fetch | Custom transport, for example for a proxy, longer timeouts, or tests |
headers | none | Merged into every request |
The client does not read FACTORY_API_KEY by itself, so pass the key. Do not
ship a Factory API key in a public website.
Headers and credentials
An operation's headers option takes a plain object, a Headers instance, or
[name, value] pairs. Its values replace the client's value of the same
header. In a plain object, null or [] removes a header for that call.
Every operation declares bearer security, so apiKey applies to it. The
client chooses the credential after it merges the client's and the call's
headers:
- If an
Authorizationheader is left, the client sends it as is and does not readapiKey. A functionapiKeyis not called. - If none is left, the client sends
apiKeyasAuthorization: Bearer <apiKey>.
Removing Authorization with null or [] does not stop the client from
sending apiKey. To send requests with no credential, build a client without
apiKey.
const withOtherKey = await factoryApi.listComputers({
client,
headers: { Authorization: `Bearer ${process.env.OTHER_FACTORY_API_KEY}` },
});The client's request method and its HTTP verb methods, such as client.get
and client.post, have no security of their own. They send apiKey only when
the call passes security: [{ type: 'http', scheme: 'bearer' }]. Passing
security to an operation replaces the operation's bearer security, which
changes how that call authenticates: with security: [], the call sends no
API key.
Call an operation
Each operation takes one options object: client, plus path, query, and
body when the operation has them.
const computer = await factoryApi.getComputer({
client,
path: { computerId: 'computer-id' },
});
const sessions = await factoryApi.listSessions({
client,
query: { limit: 5 },
});Results and errors
An operation resolves with data, error, request, and response. It
resolves with error only when the server answered with a non-2xx status, so
an HTTP error does not throw. The client always sets response with error,
but its type allows undefined, so read it as response?.status.
error is the response body as the server sent it: its parsed JSON when the
response's Content-Type is JSON, which can be null, false, or 0, and
its text otherwise, which can be empty. Test error !== undefined, not
truthiness.
The API's error bodies are JSON with detail, status, and title, and can
carry code and requestId. Include requestId when you contact support. A
415 body, and a 400 from request validation, carry message instead of
detail.
On any operation that takes a body, the platform edge refuses a body over its
size limit before the API runs. It answers 413 with a plain-text body such as
Request Entity Too Large followed by FUNCTION_PAYLOAD_TOO_LARGE. The
client returns a plain-text body as a string, so check
typeof error === 'string' before you read JSON fields. If you send
Accept: application/json, the edge answers the same 413 as JSON,
{"error": {"code": "413", "message": "Request Entity Too Large"}}, which
has neither detail nor a top-level message, and which the operation types
do not describe. Some operations, such as createSession and postMessage,
can also answer 413 from the API itself, with the JSON error body described
above. This helper turns any error result into an Error, and the rest of
this guide uses it:
function apiError(
operation: string,
error: unknown,
response: Response | undefined
): Error {
const status = response?.status ?? 'no status';
if (typeof error === 'string') {
// Plain text, such as the platform edge's 413.
return new Error(`${operation} failed (${status}): ${error}`);
}
if (typeof error === 'object' && error !== null) {
if ('detail' in error) {
const requestId =
'requestId' in error ? ` [request ${String(error.requestId)}]` : '';
return new Error(
`${operation} failed (${status}): ${String(error.detail)}${requestId}`
);
}
if ('message' in error) {
return new Error(
`${operation} failed (${status}): ${String(error.message)}`
);
}
}
return new Error(`${operation} failed (${status}): ${JSON.stringify(error)}`);
}
const computers = await factoryApi.listComputers({ client });
if (computers.error !== undefined) {
throw apiError('listComputers', computers.error, computers.response);
}
for (const computer of computers.data.computers) {
console.log(computer.id, computer.name, computer.status);
}A 2xx body is decoded as JSON whatever its Content-Type. A response that has
no body is never decoded:
- A 204 or a 205 resolves with
data: undefined. - A 304 resolves with
error: '', because it is not a 2xx status. - A response to a HEAD request resolves with
data: undefinedon a 2xx status and witherror: ''otherwise.
Every other failure rejects the call with the original thrown value:
| Failure | Rejects with |
|---|---|
Network or DNS failure, or a fetch timeout | TypeError from fetch, or what a custom fetch throws |
Abort through signal | DOMException named AbortError, or the signal's reason |
| A non-2xx body labeled JSON that is not valid JSON, unless the response is a 304 or answers HEAD | SyntaxError |
| A non-2xx body that cannot be read | The body stream's error |
| A 2xx body that is not valid JSON, unless the response is a 204 or a 205 or answers HEAD | SyntaxError |
A path parameter that is empty, ., or .. | TypeError, before any request is sent |
| A value thrown by a request or response interceptor | That value, whatever the status |
Pass throwOnError: true to throw HTTP error bodies as well. A caught value is
then either an HTTP error body or one of the failures above, so leave
throwOnError off when you need to tell them apart.
Add interceptors through client.interceptors.request,
client.interceptors.response, and client.interceptors.error. Error
interceptors run only for HTTP error bodies, after the body is decoded, and
what they return becomes error.
Paginate with a cursor
Operations that return pages, such as listSessions, getMessages, and
listWorkstreams, take limit (1 to 100) and cursor and return
pagination.hasMore and pagination.nextCursor. nextCursor is null on the
last page.
let cursor: string | undefined;
do {
const page = await factoryApi.listSessions({
client,
query: { limit: 100, cursor },
});
if (page.error !== undefined) {
throw apiError('listSessions', page.error, page.response);
}
for (const session of page.data.sessions) {
console.log(session.sessionId, session.title, session.status);
}
cursor = page.data.pagination.nextCursor ?? undefined;
} while (cursor !== undefined);Open response values
Every string enum in a response is typed as open, including one with a single
known value: for example status: 'idle' | 'pending' | 'running' | (string & {})
or fileRole?: 'spec' | (string & {}). A value the server adds later is then
not a breaking type change, so code that reads one must handle a value it does
not know. Give a switch over an open field a default branch, and do not
check it exhaustively with never. The samples in this guide compare with a
known literal, so a value they do not know takes the same path as any other
value that does not match.
The exception is a union tag: a field such as kind or type whose single
value tells apart the members of a union, as in kind: 'computer' beside
kind: 'environment'. A tag stays closed so that checking it narrows the
union.
Request fields stay closed, so a value the server would reject fails typecheck. To send back a value you read from a response, compare it with a known literal and send the literal.
Names shared with other entrypoints
The root entrypoint has REST helpers too. They call the same endpoints with a different contract:
@factory/droid-sdk/api operations | Root REST helpers | |
|---|---|---|
| Options | client from createFactoryClient() | apiKey and baseUrl |
| HTTP error | Resolves with error | Rejects |
| Success | Resolves with data and response | Resolves with the body |
Thirteen names exist on both: createComputer, deleteComputer,
getComputer, getComputerByName, getComputerMetrics, getMachineTemplate,
listComputers, listEnvironments, listMachineTemplates, refreshComputer,
restartComputer, retryInstallDeps, and updateComputer.
@factory/droid-sdk/node re-exports those and adds createSession and
listSessions, which start and list local Droid sessions. The ./api
operations with those two names create and list remote sessions.
Import ./api under a namespace, as this guide does, so the two sets never
share a scope.
Workstreams through the REST API
The REST API starts a workstream on a Droid Computer, or on a new computer from an environment, without a daemon connection. The orchestrator works through a brief, starts agents for parts of the work, and shares a folder with them. To run the orchestrator in a daemon you connect to, see Workstreams.
Two operations are specific to workstreams: createWorkstream starts
one and listWorkstreams finds yours. After the start, you follow and steer a
workstream with the session operations, such as getSession, getMessages,
listChildSessions, listSessionElicitations, answerSessionElicitation,
postMessage, and interruptSession. The samples below use factoryApi,
client, and apiError from Factory REST API.
Workstreams must be turned on for your organization, and an administrator can
limit them to selected members. When workstreams are off, both workstream
operations answer 403. When they are limited to members you are not one of, a
start answers 403. Nothing runs in either case, and detail holds the first
or the second of these messages:
Workstreams are not available for this organization
Workstreams have been restricted by your organization admin. Contact your admin to request access.Create a workstream
createWorkstream sends POST /api/v0/sessions/workstreams with three
fields:
targetis where the orchestrator runs:{ kind: 'computer', computerId }for a computer you own, or{ kind: 'environment', environmentId }for an environment. For an environment target, Factory provisions a new computer from the environment's active build. This guide calls that computer the environment computer. Look up IDs withlistComputersandlistEnvironments.titleis trimmed and must be 1 to 120 characters. A longer title, or one that contains a NUL character or an unpaired surrogate, is refused with 400 before anything is provisioned. Give each start a title you can recognize when you list workstreams.briefis the orchestrator's first message. It is trimmed and must not be empty. Only the platform's request body limit bounds its size.
The server sets everything else. The orchestrator runs in Mission Mode at High autonomy, so it runs commands without asking for approval, and every agent it starts runs at its level. If an administrator has set a lower maximum autonomy level for your organization, the orchestrator and its agents run at that maximum, and a command that needs more autonomy does not run until someone approves it. The workstream is private.
Both workstream operations also take the optional X-Factory-Org-Id header,
which picks the organization a request signed in as a user acts in. An API
key always acts in its own organization and ignores the header.
A 201 means the orchestrator session exists and has received the brief. The
orchestrator keeps working after the response. The body holds sessionId,
the orchestrator's session; computerId, the computer it runs on (the
environment computer, for an environment target); and messageId, the
brief's message.
An environment computer stays with its workstream until you delete it with
deleteComputer, or until it is removed after a long period of inactivity.
Give the start enough time
createWorkstream can take up to 800 seconds to answer. At 800 seconds the
platform ends the request. A start on an environment provisions and boots the
environment computer first, so it takes longer than a start on a computer
that is already running. In one observed run, a start on a running computer
answered 201 after 2.4 seconds and a start on an environment after 12.6
seconds; neither number is a bound. Use a client timeout above 800 seconds for
createWorkstream, so a slow start ends on the server's side and not on
yours.
In Node.js, the built-in fetch stops waiting for response headers after 300
seconds and rejects with TypeError: fetch failed, whose cause has the code
UND_ERR_HEADERS_TIMEOUT. Pass createWorkstream a fetch with longer
timeouts, using an Agent from version 7 of the undici package
(npm install undici@7), which needs Node.js 20.18.1 or later:
import { Agent } from 'undici';
const longRequests = new Agent({
headersTimeout: 900_000,
bodyTimeout: 900_000,
});
const startFetch: typeof fetch = (input, init) =>
fetch(input, { ...init, dispatcher: longRequests } as RequestInit);A call's fetch replaces the client's for that request only; the call still
uses the client's base URL, headers, and credentials.
An Agent from undici 8 does not work here: the built-in fetch of Node.js 22
and 24 rejects it with TypeError: fetch failed, whose cause is
invalid onRequestStart method.
When a start fails
Each status below has one of the API's JSON error bodies except the 413,
which comes from the platform edge: plain text, or the edge's own JSON when
the request sends Accept: application/json.
| Status | Cause | What to do |
|---|---|---|
| 400 | The body failed validation, for example an empty brief or a title over 120 characters | Fix the request |
| 401 | Missing or invalid credentials | Check the API key |
| 402 | Your organization's plan does not cover the request | Resolve the billing issue that detail names |
| 403 | Workstreams are not available to you (see the two messages above) | Ask an administrator for access |
| 404 | The computer or environment does not exist or is not yours | Check the ID with listComputers or listEnvironments |
| 409 | The environment is not ready to provision from | Start again once the environment's build is ready |
| 413 | The request body is over the platform's size limit | Shorten the brief |
| 415 | The request body is not JSON | Send a JSON body |
| 424 | The computer's Droid is too old for workstreams, or the computer is offline or disconnected | See below |
| 429 | Too many requests, or the computer is at its limit of concurrent sessions | Wait, then check before you retry |
| 451 | Your organization's region does not match the deployment you called | Call the deployment for your organization's region |
| 500 | Unexpected failure | Check before you retry |
| 503 | The computer is unreachable or lost its connection during the start | Check before you retry |
| 504 | The computer did not answer in time | Check before you retry |
A 424 for a computer whose Droid is too old arrives before anything starts.
The computer's Droid must support protocol version 1.301.0 or later. The
detail names the computer and says what to do:
Computer <computerId> runs a version of Droid too old for workstreams. Update Droid on that computer, or rebuild the environment it was created from so its build carries a current Droid, then start the workstream again.A 424 because the computer is offline can arrive after the orchestrator started.
createWorkstream has no idempotency key, and two identical requests start
two workstreams. The 400, 401, 402, 403, 404, 409, 413, 415, and 451 refusals
and the too-old 424 mean nothing started, so you can fix the cause and start again.
After any other error, or when the request got no response, the workstream
can have started: list your workstreams and look for its
title before you start again. A start whose connection dropped can still be
running on the server, so wait until 800 seconds after you sent it before you
decide from the list.
Clean up after an environment start fails
When the API answers an environment start with an error, Factory deletes the environment computer before it answers. If that deletion fails, Factory does not retry it. The environment computer also remains when the request is cut off before an answer: your client timed out or lost the connection, or the platform ended the request at 800 seconds, which deletes nothing.
An error response does not say whether that deletion succeeded, and a cut-off
request tells you nothing. The computer can also remain when the start failed
before any workstream existed on it, so look for the computer, not the
workstream. A computer from an environment is unlisted, so list with
includeUnlisted; its computerSource names the environment. List the
environment's computers before you send the start and again afterwards. Both
lists come from Factory, so your clock plays no part.
// The computers the environment has given you.
async function environmentComputerIds(
environmentId: string
): Promise<Set<string>> {
const listed = await factoryApi.listComputers({
client,
query: { includeUnlisted: true },
});
if (listed.error !== undefined) {
throw apiError('listComputers', listed.error, listed.response);
}
return new Set(
listed.data.computers
.filter(
(computer) =>
computer.computerSource?.kind === 'template' &&
computer.computerSource.templateId === environmentId
)
.map((computer) => computer.id)
);
}
const before = await environmentComputerIds('environment-id');
// Send the start here. If it fails or is cut off, wait until 800 seconds
// after you sent it, then:
const after = await environmentComputerIds('environment-id');
const candidates = [...after].filter((id) => !before.has(id));Each candidate is a computer the environment gave you after the first list.
Besides the failed start's own computer, it can be any other computer started
from that environment since then, such as one for a retry. A computer a
workstream runs on is the computerId of its row in
your workstreams. Once you have confirmed
that a candidate belongs to the failed start, delete it with deleteComputer,
which archives the sessions on it.
With a computer target, the computer is yours and outlives the workstream. To stop a workstream you do not want there, interrupt it.
List the workstreams you started
listWorkstreams (GET /api/v0/sessions/workstreams) returns the workstreams
you started, most recently updated first, with the same session fields as
listSessions. It lists orchestrators only; read a workstream's agents with
listChildSessions.
limitis 1 to 100 and defaults to 20. A value outside that range is a 400.cursoris the previous page'snextCursor. Because the list is ordered by last update, a workstream updated while you page can move to an earlier page. A cursor that names none of your sessions starts the list again from the most recently updated workstream instead of failing.- The operation ignores query parameters it does not define, such as
computerId. - Besides 400 for a bad
limit, it answers 403 when workstreams are off for your organization and 451 when your organization's region does not match the deployment you called.
In each row:
statusisidle,pending,running, or a value added later, read from the session state Factory stores. It describes the orchestrator alone: an orchestrator readsidlewhile its agents are still working or waiting for an answer.computerIdis the computer the orchestrator runs on: your computer on the session's host, otherwise the computer the session records. It is absent when neither exists.tagsholds amission-v2tag whose metadata haskindset toorchestratorandmissionIdequal to the row'ssessionId.spaceIdappears once the workstream's shared folder is attached.
listSessions with a computerId reads from that computer instead, and
reports every row as idle because the computer's session list records no
run state. For whether a session is running, read it with getSession.
Follow a workstream to the end
This example starts a workstream on an environment, answers the questions
its orchestrator and agents ask, waits until they are done, and prints the
orchestrator's last report. Its start uses startFetch from
Give the start enough time.
const title = `Billing v2 migration ${new Date().toISOString()}`;
const started = await factoryApi.createWorkstream({
client,
fetch: startFetch,
body: {
target: { kind: 'environment', environmentId: 'environment-id' },
title,
brief:
'Move every caller of the billing v1 client to v2. Open one pull request per service and keep CI passing.',
},
});
if (started.error !== undefined) {
throw apiError('createWorkstream', started.error, started.response);
}
const { sessionId } = started.data;
// Answers every AskUser question a session is waiting on.
async function answerQuestions(id: string): Promise<void> {
const pending = await factoryApi.listSessionElicitations({
client,
path: { sessionId: id },
});
if (pending.error !== undefined) {
throw apiError('listSessionElicitations', pending.error, pending.response);
}
for (const item of pending.data.elicitations) {
if (item.type !== 'ask-user') {
continue;
}
const answered = await factoryApi.answerSessionElicitation({
client,
path: { sessionId: id, requestId: item.requestId },
body: {
// Decide each answer here; this sample takes the first option.
answers: item.questions.map((question) => ({
index: question.index,
question: question.question,
answer: question.options[0] ?? 'Use your best judgment.',
})),
},
});
// 409: another client answered first, or the question's turn ended.
if (answered.error !== undefined && answered.response?.status !== 409) {
throw apiError('answerSessionElicitation', answered.error, answered.response);
}
}
}
async function readAgents() {
const agents = await factoryApi.listChildSessions({
client,
path: { sessionId },
query: { includeAttention: true },
});
if (agents.error !== undefined) {
throw apiError('listChildSessions', agents.error, agents.response);
}
return agents.data.sessions;
}
async function readOrchestrator() {
const orchestrator = await factoryApi.getSession({
client,
path: { sessionId },
});
if (orchestrator.error !== undefined) {
throw apiError('getSession', orchestrator.error, orchestrator.response);
}
return orchestrator.data;
}
// Answers the questions the agents and the orchestrator are waiting on.
async function answerWaiting(): Promise<void> {
for (const agent of await readAgents()) {
if (agent.attention?.kind === 'ask-user') {
await answerQuestions(agent.sessionId);
}
}
if ((await readOrchestrator()).status !== 'idle') {
await answerQuestions(sessionId);
}
}
// Done when the orchestrator is idle and unchanged across these reads, every
// agent is idle, and the orchestrator's newest message is a reply it wrote
// after its last agent stopped. Right after an agent finishes, the
// orchestrator still reads idle, with an earlier reply as its newest message,
// until the agent's report starts its next turn.
async function isDone(): Promise<boolean> {
const orchestrator = await readOrchestrator();
if (orchestrator.status !== 'idle') {
return false;
}
const agents = await readAgents();
if (agents.some((agent) => agent.status !== 'idle')) {
return false;
}
const lastAgentStop = Math.max(0, ...agents.map((agent) => agent.updatedAt));
const newest = await factoryApi.getMessages({
client,
path: { sessionId },
query: { limit: 1 },
});
if (newest.error !== undefined) {
throw apiError('getMessages', newest.error, newest.response);
}
const message = newest.data.messages[0];
if (message?.role !== 'assistant' || message.createdAt <= lastAgentStop) {
return false;
}
return (await readOrchestrator()).updatedAt === orchestrator.updatedAt;
}
do {
await new Promise((resolve) => setTimeout(resolve, 30_000));
await answerWaiting();
} while (!(await isDone()));
const report = await factoryApi.getMessages({
client,
path: { sessionId },
query: { role: 'assistant', limit: 1 },
});
if (report.error !== undefined) {
throw apiError('getMessages', report.error, report.response);
}
for (const block of report.data.messages[0]?.content ?? []) {
if (block.type === 'text') {
console.log(block.text);
}
}Details the example relies on:
getMessagespages start with the newest message, andnextCursormoves toward older ones.- A session's
updatedAtmoves whenever its status changes. The sameupdatedAton both reads of the orchestrator means it ran no turn in between, so it started no agent the list misses and wrote no newer message. listChildSessionslists the orchestrator's agents, oldest first. Each agent'supdatedAtis when its session last changed, and itsstatushas held at least since then. WithincludeAttention, each agent'sattentionnames what it is waiting on:ask-userfor a question,permissionfor a permission prompt, or a kind added later; it isnullwhen the agent waits on nothing.- Act on an agent by its own
sessionId, with the same session operations as the orchestrator. InlistChildSessions, an agent that records no computer, such as one running on the orchestrator's machine, hascomputerId: null;getSessionon the agent reports the computer it runs on. - Send exactly one answer per question, matched by
index, or sendcancelled: trueto dismiss the questions.listSessionElicitationsdoes not return option pickers or AskUser questions the agent does not wait on; answer those in the Factory app or CLI. - Because a workstream runs at High autonomy, permission prompts appear only
when your organization caps autonomy below High, and the loop above keeps
waiting until someone answers them. Answer one with
selectedOptionset to one of the prompt'soptions[].value, for exampleproceed_once. answerSessionElicitationanswers 503 when the session's computer runs a Droid version that cannot answer elicitations through this API.
Read the shared folder
@factory/droid-sdk/api has no operation for a workstream's
shared folder: the folder's documents are not
part of the Factory API spec that the client is generated from. Read them
with the shared-context helpers that the root entrypoint @factory/droid-sdk
exports: listContextDocuments, getContextDocument (by document ID), and
getContextDocumentByPath. It also exports createContextDocument,
updateContextDocument, and deleteContextDocument for writes.
These helpers do not take a client. They take apiKey and baseUrl, and
baseUrl is required: it is the host that serves /api/context for your
organization, which https://api.factory.ai does not. On an HTTP error they
throw ContextApiError, whose status holds the HTTP status.
The folder's ID is the spaceId in the orchestrator's mission-v2 tag. It
appears once the shared folder is attached.
import { getContextDocumentByPath } from '@factory/droid-sdk';
const apiKey = process.env.FACTORY_API_KEY;
const contextHost = process.env.CONTEXT_HOST;
if (apiKey === undefined || contextHost === undefined) {
throw new Error('Set FACTORY_API_KEY and CONTEXT_HOST');
}
const orchestrator = await factoryApi.getSession({
client,
path: { sessionId },
});
if (orchestrator.error !== undefined) {
throw apiError('getSession', orchestrator.error, orchestrator.response);
}
const spaceId = orchestrator.data.tags?.find(
(tag) => tag.name === 'mission-v2'
)?.metadata?.spaceId;
if (spaceId !== undefined) {
const document = await getContextDocumentByPath({
apiKey,
baseUrl: contextHost,
spaceId,
path: 'notes/plan.md',
});
console.log(document.version, document.body);
}Message or interrupt a workstream
These samples act on an agent, with agentId set to a sessionId from
listChildSessions. postMessage returns once the computer accepts the
message, and the turn runs asynchronously. recipientDroidStatus reports the
session's status before the message: idle means the message starts a turn
now, running means it is queued behind the current turn, and another value
is one this guide does not cover.
const sent = await factoryApi.postMessage({
client,
path: { sessionId: agentId },
body: { text: 'Pause the schema migration and report what is left.' },
});
if (sent.error !== undefined) {
throw apiError('postMessage', sent.error, sent.response);
}
console.log(sent.data.messageId, sent.data.recipientDroidStatus);interruptSession stops the session's running turn. It changes nothing when
the session is already idle.
const interrupted = await factoryApi.interruptSession({
client,
path: { sessionId: agentId },
});
if (interrupted.error !== undefined) {
throw apiError('interruptSession', interrupted.error, interrupted.response);
}Both operations work on the orchestrator and on each agent. To stop a whole
workstream, call interruptSession on the orchestrator and on each running
agent.
Examples
| Example | Demonstrates |
|---|---|
| Multi-turn session | Keep context across prompts |
| Initialization metadata | Read session metadata after initialization |
| Session settings | Read and update active-session settings |
| Rename a session | Change a saved session's title |
| List sessions | Discover saved sessions |
| Model discovery | Discover selectable and disabled models |
| Session stream | Consume complete stream messages |
| Result metadata | Inspect terminal result metadata |
| Abort a stream | Cancel a turn with an abort signal |
| Interrupt a session | Request interruption during a turn |
| Image attachment | Send a local image with a prompt |
| Structured output | Validate model output against a schema |
| Permission handler | Respond to tool permission requests |
| AskUser handler | Answer questions from Droid |
| Tool controls | Restrict and configure native tools |
| Enter Spec mode | Plan before implementation |
| SDK MCP tool | Define an in-process MCP tool |
| Hook execution | Observe configured hook events |
| Observability | Capture SDK logs and telemetry |
| Start a workstream | Start a workstream on a daemon and leave it running |
| REST API | List Computers and remote sessions with the REST client |
API reference
Entrypoints
| Import | Runtime | Purpose |
|---|---|---|
@factory/droid-sdk/node | Node.js | Start Droid subprocesses, run prompts, manage local sessions, and define SDK MCP tools |
@factory/droid-sdk | Browser or Node.js | Connect to an existing Droid daemon and use browser-safe schemas, types, and REST helpers |
@factory/droid-sdk/api | Browser or Node.js | Call any Factory REST API operation through the generated client |
The tables below cover @factory/droid-sdk/node. See
Factory REST API for @factory/droid-sdk/api.
Functions
| API | Returns |
|---|---|
run(prompt, options?) | Promise<DroidResult> |
createSession(options?) | Promise<DroidSession> |
resumeSession(sessionId, options?) | Promise<DroidSession> |
listSessions(options?) | Promise<SessionMetadata[]> |
listModels(options?) | Promise<ModelInfo[]> |
createSdkMcpServer(options) | SdkMcpServer |
tool(...) | DroidTool |
DroidSession
| Member | Purpose |
|---|---|
id | Session ID |
cwd | Live working directory |
settings | Live read-only settings |
stream() | Run one turn |
interrupt() | Stop the active turn |
updateSettings() | Update session settings |
enterSpecMode() | Change to spec mode |
listTools() | List normalized tools |
listSkills() | List skills |
setSkillDisabled() | Enable or disable a skill |
addMcpServer() | Add an MCP server |
removeMcpServer() | Remove an MCP server |
toggleMcpServer() | Enable or disable an MCP server |
listMcpServers() | List MCP servers |
listMcpTools() | List MCP tools |
authenticateMcpServer() | Start MCP authentication |
getContextStats() | Read context usage |
getRewindInfo() | Inspect rewindable file changes |
rewind() | Create a rewound successor |
compact() | Create a compacted successor |
fork() | Create a forked successor |
rename() | Change the session title |
onNotification() | Subscribe to raw notifications |
close() | Close the session and owned resources |
droid.unstable.workstreams
Daemon clients reach these from connectToDaemon(). They are experimental.
| API | Returns |
|---|---|
start(options) | Promise<ConnectedDroidSession> |
list(options?) | Promise<WorkstreamSummary[]> |
updateSettings(orchestrator, patch) | Promise<WorkstreamSettings> |
droid.sessions.interrupt(sessionId) stops any open session's turn by ID.
Stream types
| Type | Purpose |
|---|---|
DroidStreamMessage | Complete messages returned by the default stream |
DroidStreamEvent | Complete messages plus partial events |
DroidResult | Terminal result returned by run() |
DroidMessageType | Runtime constants for narrowing stream messages |
TokenUsage | Token counts and optional Factory Service Credits |
ModelInfo | Model catalog entry returned by discovery APIs |
session.stream() yields DroidStreamMessage by default and DroidStreamEvent when includePartialMessages is true.
Result states
| Subtype | success | interrupted | Meaning |
|---|---|---|---|
success | true | false | Turn completed |
interrupted | false | true | Turn was stopped |
error_during_execution | false | false | Runtime failure |
error_structured_output | false | false | Structured-output failure |
Every result includes sessionId, durationMs, tokenUsage, messages, text, and turnCount.
Check success before using successful output.
Main enums
| Enum | Values |
|---|---|
AutonomyLevel | Off, Low, Medium, High |
DroidInteractionMode | Auto, Spec, Mission |
ReasoningEffort | None, Dynamic, Off, Minimal, Low, Medium, High, ExtraHigh, Max |
OutputFormatType | JsonSchema |
WorkstreamMachineTargetKind | Local, Computer, Template |
Model IDs and tool IDs are strings rather than closed enums.
Main errors
| Error | Meaning |
|---|---|
ConcurrentStreamError | A session handle already has an active stream |
SessionReplacedError | A source wrapper was replaced |
SessionReplacementError | Successor loading or rollback failed |
DroidClientError | Base client error |
ConnectionError | Droid process connection failed |
TimeoutError | A request timed out |
ProtocolError | Protocol or API response failed |
SessionError | Base session error |
SessionNotFoundError | Saved session was not found |
InvalidSessionCwdError | Saved working directory is invalid |
ProcessExitError | Droid subprocess exited unexpectedly |
Known limitations
- New-session spec handoff IDs require raw notification inspection.
- SDK MCP tools require compatible protocol versions in the npm package and Droid CLI.