| title | Building Interactive Apps with Droid Exec |
|---|---|
| description | Learn how to build a chat with X feature using Droid Exec |
- How to build a "chat with repo" feature using Factory's Droid Exec in headless mode
- Setting up streaming responses with Server-Sent Events for real-time agent feedback
- Understanding the actual implementation from Factory's official example
- Bun (the example uses Bun, but Node.js works too)
- Factory CLI installed (
droidon your PATH) - A local repository to chat with
# Install Bun
curl -fsSL https://bun.sh/install | bash
# Install Factory CLI
curl -fsSL https://app.factory.ai/cli | sh
# Sign in to Factory (one-time browser auth)
droidAfter the browser login, droid exec works from your app without needing API keys in code.
git clone https://github.com/Factory-AI/examples.git
cd examples/droid-chat
bun i
bun devOpen http://localhost:4000 - you'll see a chat window overlaid on the repo's README.
Building AI features that understand codebases requires orchestrating multiple operations: searching files, reading code, analyzing structure, and synthesizing answers. Without droid exec, a question like "How do we charge for MCP servers?" would require dozens of API calls and custom logic to search, read, and understand the relevant code.
droid exec is Factory's headless CLI mode that handles this autonomously in a single command. It searches codebases, reads files, reasons about code structure, and returns structured JSON output—with built-in safety controls and configurable autonomy levels. Perfect for building chat interfaces, CI/CD automation, or any application that needs codebase intelligence.
The Factory example uses a simple pattern: spawn droid exec with --output-format debug and stream the results via Server-Sent Events (SSE).
// Simplified from src/server/chat.ts
function runDroidExec(prompt: string, repoPath: string) {
const args = ["exec", "--output-format", "debug"];
// Optional: configure model (defaults to claude-opus-4-5-20251101)
const model = process.env.DROID_MODEL_ID ?? "claude-opus-4-5-20251101";
args.push("-m", model);
// Optional: reasoning level (off|low|medium|high)
const reasoning = process.env.DROID_REASONING;
if (reasoning) {
args.push("-r", reasoning);
}
args.push(prompt);
return Bun.spawn(["droid", ...args], {
cwd: repoPath,
stdio: ["ignore", "pipe", "pipe"]
});
}--output-format debug: Streams structured events as the agent works
- Each tool call (file read, search, etc.) emits an event
- Lets you show real-time progress to users
- Alternative:
--output-format jsonfor final output only
-m (model): Choose your AI model
claude-opus-4-5-20251101- Default, strongest reasoninggpt-5.2-codex- Most powerful for complex codeclaude-sonnet-4-5-20250929- Best balance of speed and capability
-r (reasoning): Control thinking depth
off- No reasoning, fastestlow- Light reasoningmedium|high- Deeper analysis, slower
No --auto flag?: Defaults to read-only (safest)
- Can't modify files, only read/search/analyze
- Perfect for chat applications
The Factory example streams agent activity in real-time using Server-Sent Events. This gives users immediate feedback as the agent searches, reads files, and thinks.
// Simplified from src/server/chat.ts
export async function handleChatRequest(req: Request): Promise<Response> {
const { message, history } = await req.json();
// Get repo info (finds ./repos/<folder>)
const repoInfo = await getLocalRepoInfo();
// Build prompt with history
const prompt = buildPrompt(message, history);
// Spawn droid exec
const proc = runDroidExec(prompt, repoInfo.workdir);
// Create SSE stream
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// Helper to send events
const send = (event: string, data: any) => {
controller.enqueue(encoder.encode(`event: ${event}\n`));
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
// Read stdout and parse debug events
const reader = proc.stdout.getReader();
let buffer = "";
(async () => {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += new TextDecoder().decode(value);
buffer = parseAndFlush(buffer, (event, data) => {
send(event, data);
});
}
})();
// When process exits, close stream
proc.exited.then((code) => {
send("exit", { code });
controller.close();
});
}
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
});
}When --output-format debug is used, droid emits events like:
event: tool_call
data: {"tool":"grep","args":{"pattern":"MCP"}}
event: assistant_chunk
data: {"text":"I found references to MCP servers in..."}
event: tool_result
data: {"files_found":["src/billing.ts","config/pricing.yml"]}
event: exit
data: {"code":0}
// Simplified from src/hooks/useChat.ts
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const sendMessage = async (text: string, history: Message[]) => {
// Add user message
setMessages(prev => [...prev, { role: "user", content: text }]);
// Start SSE connection
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, history })
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assistantMessage = { role: "assistant", content: "" };
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("event:")) {
const event = line.slice(7);
const dataLine = lines[++i];
const data = JSON.parse(dataLine.slice(6));
if (event === "assistant_chunk") {
// Append to assistant message
assistantMessage.content += data.text;
setMessages(prev => {
const newMessages = [...prev];
if (newMessages[newMessages.length - 1]?.role !== "assistant") {
newMessages.push({ ...assistantMessage });
} else {
newMessages[newMessages.length - 1] = { ...assistantMessage };
}
return newMessages;
});
}
if (event === "exit") {
// Done
break;
}
}
}
}
};
return { messages, sendMessage };
}In the demo video, the user asked: "Can you search for how we charge for MCP servers?"
Behind the scenes, droid exec automatically:
- Searched the codebase with ripgrep for "MCP", "charge", "payment"
- Read relevant files (billing config, pricing logic, env vars)
- Analyzed the code structure to understand the charging flow
- Synthesized a complete answer with file locations, variable names, and implementation details
All streamed in real-time through SSE - no manual orchestration needed.
examples/droid-chat/
├── src/
│ ├── server/
│ │ ├── index.ts # Bun HTTP server + static files
│ │ ├── chat.ts # SSE endpoint, runs droid exec
│ │ ├── repo.ts # Finds local repo in ./repos/
│ │ ├── prompt.ts # System prompt + history formatting
│ │ └── stream.ts # Parses debug output, strips paths
│ ├── components/chat/ # React chat UI
│ └── hooks/useChat.ts # Client-side SSE parsing
├── repos/ # Your repositories to chat with
│ └── your-repo/
└── public/ # Static assets
The example supports environment variables:
# .env
DROID_MODEL_ID=gpt-5.2-codex # Default: claude-opus-4-5-20251101
DROID_REASONING=low # Default: low (off|low|medium|high)
PORT=4000 # Default: 4000
HOST=localhost # Default: localhost✅ Do:
- Use read-only mode (no
--autoflag) for user-facing features - Validate user input before passing to
droid exec - Set timeouts (example uses 240 seconds)
- Parse SSE events incrementally for responsive UI
- Strip local file paths from debug output before sending to client
- Using
--auto medium/highin production without sandboxing - Passing unsanitized user input directly to the CLI
- Blocking the main thread while waiting for results
The example ships with a local repo, but you can easily adapt it:
PDFs & Documents:
// Extract text from PDFs, write to temp dir, point droid at it
import { pdfToText } from 'pdf-to-text';
const text = await pdfToText('document.pdf');
fs.writeFileSync('/tmp/docs/content.txt', text);
runDroidExec("Summarize this document", '/tmp/docs');Databases:
// Add database context to prompt
const prompt = `You have access to a PostgreSQL database with these tables:
${JSON.stringify(schema)}
User question: ${message}`;
runDroidExec(prompt, repoPath); // Can read SQL files in repoWebsites:
// Crawl site, save markdown, chat with it
import TurndownService from 'turndown';
const markdown = new TurndownService().turndown(html);
fs.writeFileSync('./repos/site-content/page.md', markdown);// Let users pick models
function runWithModel(prompt: string, model: string) {
return Bun.spawn([
"droid", "exec",
"-m", model, // claude-opus-4-5-20251101, gpt-5.2-codex, etc.
"--output-format", "debug",
prompt
], { cwd: repoPath });
}The example's stream.ts parses debug events. You can surface them in the UI:
if (event === "tool_call") {
// Show: "🔍 Searching for 'MCP charge'"
// Show: "📄 Reading src/billing.ts"
}This creates a transparent, trust-building experience where users see exactly what the agent is doing.
Official Example:
- GitHub: droid-chat example - Full working code
Documentation:
- Droid Exec Overview - Complete CLI reference
- Autonomy Levels Guide - Understanding
--autoflags - CI/CD Cookbook - Production patterns
- Model Configuration - Available models and settings
Community:
- Factory Discord - Get help from the team
- GitHub Discussions - Share your builds
- Clone the example:
git clone https://github.com/Factory-AI/examples.git - Run it locally:
cd examples/droid-chat && bun dev - Explore the code in
src/server/chat.tsto see how SSE streaming works - Customize
src/server/prompt.tsto change the agent's behavior - Swap
./repos/content to chat with your own repositories
The example is intentionally minimal (~500 lines total) so you can understand it fully and adapt it to your needs.