Overview
Imaginate is a vibe coding web application. You give it a prompt describing what you want to build, and it generates a full working web application — writing code, installing dependencies, and running it in a live sandbox you can interact with immediately. It supports multiple AI providers (OpenAI, Anthropic, Gemini) and has two interaction modes: "Code" for full app generation and "Ask" for conversational Q&A.
The stack is Next.js 15, TypeScript, Prisma with PostgreSQL, tRPC, Vercel AI SDK for the coding agent, E2B for sandboxed code execution, and Inngest for async orchestration. Authentication is disabled — anyone can create a shared project without signing in.
Try Imaginate or explore the source code.
Architecture
Agent Workflow
The core of Imaginate is a coding agent built with Vercel AI SDK. The agent has access to tools for terminal commands, file creation, and file reading. A single generateText call orchestrates the agent loop, iterating until it either produces a <task_summary> tag or reaches the 15-step limit:
const result = await generateText({
model: createModelProvider(modelConfig),
system: AGENT_PROMPT,
messages,
tools: {
terminal: terminalTool,
createOrUpdateFiles: createOrUpdateFilesTool,
readFiles: readFilesTool,
},
maxOutputTokens: 4096,
stopWhen: [
stepCountIs(15),
({ steps }) => {
const last = steps[steps.length - 1];
return (
typeof last?.text === "string" && last.text.includes("<task_summary>")
);
},
],
});
After the coding agent finishes, two lightweight generateText calls run in sequence — one generates a title for the created app, and another generates a user-friendly response message.
E2B Sandbox Integration
Each code generation session runs in an isolated E2B sandbox — a cloud-based micro-VM with a full file system and terminal. The sandbox comes pre-configured with a Next.js environment and a running dev server on port 3000. The agent writes files and runs commands inside this sandbox, and the result is a live URL the user can open in their browser:
const sandboxId = await step.run("get-sandbox-id", async () => {
const sandbox = await Sandbox.create("imaginate-dev");
await sandbox.setTimeout(SANDBOX_TIMEOUT);
return sandbox.sandboxId;
});
// Later, after agent execution:
const sandboxUrl = await step.run("get-sandbox-url", async () => {
const sandbox = await getSandbox(sandboxId);
const host = sandbox.getHost(3000);
return `https://${host}`;
});
Tool System
The agent interacts with the sandbox through three tools. The terminal tool runs shell commands, createOrUpdateFiles writes files and tracks them in a local state object, and readFiles reads file contents for the agent to inspect:
const createOrUpdateFilesTool = tool({
description: "Create or update files in the sandbox",
inputSchema: z.object({
files: z.array(z.object({ path: z.string(), content: z.string() })),
}),
execute: async ({ files }) => {
const written = await step.run(
nextStepId("createOrUpdateFiles"),
async () => {
try {
const sandbox = await getSandbox(sandboxId);
const out: Record<string, string> = {};
for (const file of files) {
await sandbox.files.write(file.path, file.content);
out[file.path] = file.content;
}
return { ok: true as const, files: out };
} catch (error) {
return { ok: false as const, error: String(error) };
}
},
);
if (!written.ok) return `Error: ${written.error}`;
Object.assign(filesState, written.files);
return `Wrote ${Object.keys(written.files).length} file(s).`;
},
});
The filesState object accumulates all files the agent creates during execution, which are later saved as a "Fragment" in the database so users can view the generated source code.
Multi-Provider Support
API keys for OpenAI, Anthropic, and Gemini are configured globally via environment variables (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY). The UI shows available models only for providers with a configured key. The model factory creates the appropriate SDK client and resolves to the selected model or falls back to the default:
export function createModelProvider(
config: ResolvedModelConfig,
): LanguageModel {
switch (config.provider) {
case "openai":
return createOpenAI({ apiKey: config.apiKey })(config.model);
case "anthropic":
return createAnthropic({ apiKey: config.apiKey })(config.model);
case "gemini":
return createGoogleGenerativeAI({ apiKey: config.apiKey })(config.model);
}
}
Model selection is persisted to localStorage and survives reloads; it's cleared automatically if the chosen provider's key becomes unavailable.
Fragment System
When the agent finishes generating an app, the result is stored as a "Fragment" — a record that captures the sandbox URL, a generated title, and all the files the agent created. Fragments are associated with messages in the conversation, so users can scroll through their chat history and revisit any previously generated app:
return prisma.message.create({
data: {
projectId: event.data.projectId,
content: responseText,
role: "ASSISTANT",
type: "RESULT",
fragment: {
create: {
sandboxUrl,
title: fragmentTitle,
files: filesState,
},
},
},
});
Agent Thoughts and Live Streaming
While the coding agent runs, users can open a "see thoughts" panel that shows each step of the agent's reasoning in real time — LLM text, tool calls with arguments, tool results, and finish reasons. To make this work, the assistant message is created in a PENDING state up front and every onStepFinish callback from generateText appends a structured Thought and writes it back to the database:
onStepFinish: async (stepResult) => {
const newThought = ThoughtSchema.parse({
stepIndex: stepResult.stepNumber,
text: stepResult.text ?? "",
toolCalls: stepResult.toolCalls?.map((tc) => ({
toolName: tc.toolName,
args: tc.input,
})),
toolResults: stepResult.toolResults?.map((tr) =>
typeof tr.output === "string"
? tr.output
: JSON.stringify(tr.output)
),
reasoningText: stepResult.reasoning?.[0]?.text,
finishReason: stepResult.finishReason,
});
thoughts.push(newThought);
await prisma.message.update({
where: { id: persistedMessage.id },
data: { thoughts: thoughtsToPrismaJson(thoughts) },
});
},
The frontend polls messages every 2 seconds via tRPC's refetchInterval, so as new thoughts land in the database they show up live in the panel. A MessageStatus enum (PENDING, COMPLETE, ERROR) drives rendering — pending messages get a shimmering loader, errors render as in-chat alerts, and completed messages swap in the final fragment. Provider failures (spend limits, rate limits, auth errors) are caught around generateText, formatted with formatProviderError, and written as ERROR messages instead of silently hanging the loop.
} catch (err) {
const errorMessage = formatProviderError(err);
await step.run("save-provider-error", async () =>
prisma.message.update({
where: { id: persistedMessage.id },
data: {
content: errorMessage,
type: MessageType.ERROR,
status: MessageStatus.ERROR,
},
})
);
return { error: errorMessage };
}
The Thought schema is validated with Zod before it hits Prisma's Json column, which keeps the shape of stored thoughts stable across agent iterations and lets the modal safely destructure them on the client.
Ask vs Code Modes
Imaginate has two interaction modes. "Code" mode runs the full agent network with sandbox tools to generate working applications. "Ask" mode runs a simpler agent without tools — it just answers questions conversationally, useful for planning or discussing implementation approaches before generating code. Both modes share the same conversation history and project context.
Difficult Parts
E2B Sandbox Tool Handlers with Inngest Durability
Bridging Vercel AI SDK tool calls to E2B sandbox operations while maintaining Inngest durability has several edge cases. Terminal commands can fail silently or produce output on stderr that the agent needs to see. File writes must track state both in the sandbox and in a local state object. The sandbox has a timeout that needs periodic extension. Each tool handler runs inside an Inngest step.run call for durability, requiring a counter to generate unique step IDs for each tool invocation (since tools are called multiple times during a single generateText call):
let toolStepCounter = 0;
const nextStepId = (base: string) => `${base}-${++toolStepCounter}`;
const terminalTool = tool({
description: "Use the terminal to run commands",
inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) =>
step.run(nextStepId("terminal"), async () => {
const buffers = { stdout: "", stderr: "" };
try {
const sandbox = await getSandbox(sandboxId);
const result = await sandbox.commands.run(command, {
onStdout: (data) => {
buffers.stdout += data;
},
onStderr: (data) => {
buffers.stderr += data;
},
});
return result.stdout;
} catch (error) {
const errMsg = `Command failed:\nerror: ${error}\nstdout: ${buffers.stdout}\nstderr: ${buffers.stderr}`;
console.error(errMsg);
return errMsg;
}
}),
});
Stopping Conditions and Summary Detection
Unlike the previous agent-kit approach with a router, Vercel AI SDK's generateText uses a stopWhen array of predicates to determine when iteration should halt. The stopping logic must check both the step count and the presence of a <task_summary> tag in the final text. Getting the logic right to gracefully handle cases where the agent reaches 15 steps without producing a summary (treating this as a failure case) required careful testing.
Multi-Provider Fallback Logic
The model resolution logic must handle several edge cases: a requested model may be selected but its provider key unavailable (fall back to OpenAI or another configured provider), or no keys may be configured at all (error). The cascade walks through requested providers first, then falls back to OpenAI, ensuring a valid model is always selected when possible:
export function resolveModelConfig(
selectedModels: SelectedModels | undefined,
): ResolvedModelConfig {
const sel = selectedModels ?? {};
for (const provider of PROVIDERS) {
const requested = sel[provider];
if (!requested) continue;
const apiKey = getProviderKey(provider);
if (!apiKey) continue;
return { provider, model: requested, apiKey };
}
const openaiKey = getProviderKey("openai");
if (openaiKey) {
return {
provider: "openai",
model: DEFAULT_FALLBACK_MODEL,
apiKey: openaiKey,
};
}
throw new Error("No API key available for selected model");
}
State Management Across Tool Calls
The filesState object accumulates files as the agent creates them across multiple tool invocations within a single generateText call. Since tools are defined outside the step functions, the state must be shared via closure. Ensuring files written in a tool are properly accumulated and later saved to the database required careful handling of async errors and return values.
Try Imaginate or explore the source code.