diff --git a/bun.lock b/bun.lock
index df70f0de..f184a7a5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -26,12 +26,14 @@
"@blink-sdk/events": "workspace:*",
"@bufbuild/protobuf": "^2.9.0",
"@testing-library/react": "^16.3.0",
+ "@types/tar-stream": "^3.1.3",
"eventsource-parser": "^3.0.6",
"happy-dom": "^18.0.1",
"hono": "^4.9.7",
"msw": "^2.12.1",
"react": "19.1.2",
"react-dom": "19.1.2",
+ "tar-stream": "^3.1.7",
"tsdown": "^0.15.1",
"zod": "^4.1.9",
"zod-validation-error": "^4.0.1",
@@ -352,7 +354,7 @@
},
"packages/scout-agent": {
"name": "@blink-sdk/scout-agent",
- "version": "0.0.14",
+ "version": "0.0.15",
"dependencies": {
"@blink-sdk/compute": "^0.0.15",
"@blink-sdk/github": "^0.0.23",
diff --git a/internal/api/package.json b/internal/api/package.json
index 59f7f49f..78ba7682 100644
--- a/internal/api/package.json
+++ b/internal/api/package.json
@@ -30,12 +30,14 @@
"@blink-sdk/events": "workspace:*",
"@bufbuild/protobuf": "^2.9.0",
"@testing-library/react": "^16.3.0",
+ "@types/tar-stream": "^3.1.3",
"eventsource-parser": "^3.0.6",
"happy-dom": "^18.0.1",
"hono": "^4.9.7",
"msw": "^2.12.1",
"react": "19.1.2",
"react-dom": "19.1.2",
+ "tar-stream": "^3.1.7",
"tsdown": "^0.15.1",
"zod": "^4.1.9",
"zod-validation-error": "^4.0.1"
diff --git a/internal/api/src/client.browser.ts b/internal/api/src/client.browser.ts
index 1e513f9d..5cbeb063 100644
--- a/internal/api/src/client.browser.ts
+++ b/internal/api/src/client.browser.ts
@@ -5,6 +5,7 @@ import ChatRuns from "./routes/chats/runs.client";
import Files from "./routes/files.client";
import Invites from "./routes/invites.client";
import Messages from "./routes/messages.client";
+import Onboarding from "./routes/onboarding/onboarding.client";
import Organizations from "./routes/organizations/organizations.client";
import Users from "./routes/users.client";
@@ -34,6 +35,7 @@ export default class Client {
public readonly invites = new Invites(this);
public readonly users = new Users(this);
public readonly messages = new Messages(this);
+ public readonly onboarding = new Onboarding(this);
public constructor(options?: ClientOptions) {
this.baseURL = new URL(
@@ -101,5 +103,6 @@ export * from "./routes/agents/traces.client";
export * from "./routes/chats/chats.client";
export * from "./routes/invites.client";
export * from "./routes/messages.client";
+export * from "./routes/onboarding/onboarding.client";
export * from "./routes/organizations/organizations.client";
export * from "./routes/users.client";
diff --git a/internal/api/src/routes/agent-request.server.ts b/internal/api/src/routes/agent-request.server.ts
index 809ccd64..0e9531e5 100644
--- a/internal/api/src/routes/agent-request.server.ts
+++ b/internal/api/src/routes/agent-request.server.ts
@@ -1,8 +1,10 @@
import { BlinkInvocationTokenHeader } from "@blink.so/runtime/types";
import type { Context } from "hono";
+
import type { Bindings } from "../server";
import { detectRequestLocation } from "../server-helper";
import { generateAgentInvocationToken } from "./agents/me/me.server";
+import { handleSlackWebhook, isSlackRequest } from "./agents/slack-webhook";
export type AgentRequestRouting =
| { mode: "webhook"; subpath?: string }
@@ -23,6 +25,24 @@ export default async function handleAgentRequest(
}
return c.json({ message: "No agent exists for this webook" }, 404);
}
+
+ const incomingUrl = new URL(c.req.raw.url);
+
+ // Handle Slack webhook requests during verification flow
+ let requestBodyText: string | undefined;
+ if (isSlackRequest(routing, incomingUrl.pathname) && query.agent) {
+ const slackResult = await handleSlackWebhook(
+ db,
+ query.agent,
+ c.req.raw,
+ !!query.agent_deployment
+ );
+ if (slackResult.response) {
+ return slackResult.response;
+ }
+ requestBodyText = slackResult.bodyText;
+ }
+
if (!query.agent_deployment) {
return c.json(
{
@@ -38,7 +58,6 @@ export default async function handleAgentRequest(
404
);
}
- const incomingUrl = new URL(c.req.raw.url);
let url: URL;
if (routing.mode === "webhook") {
@@ -133,6 +152,17 @@ export default async function handleAgentRequest(
c.req.raw.headers.forEach((value, key) => {
headers.set(key, value);
});
+
+ // If we read the body as text (for Slack verification), we need to recalculate
+ // the Content-Length header. When fetch() sends a string body, it encodes it as
+ // UTF-8, which may have a different byte length than the original Content-Length.
+ // Note: Some runtimes (like Bun) auto-correct this, but Node.js throws an error
+ // if Content-Length doesn't match the actual body length.
+ if (requestBodyText !== undefined) {
+ const encoder = new TextEncoder();
+ const byteLength = encoder.encode(requestBodyText).length;
+ headers.set("content-length", byteLength.toString());
+ }
// Strip cookies from webhook requests to prevent session leakage
// Subdomain requests are on a different origin, so cookies won't be sent anyway
if (routing.mode === "webhook") {
@@ -150,8 +180,11 @@ export default async function handleAgentRequest(
let response: Response | undefined;
let error: string | undefined;
try {
+ // Use the body we already read if it's a Slack request, otherwise use the stream
+ const bodyToSend =
+ requestBodyText !== undefined ? requestBodyText : c.req.raw.body;
response = await fetch(url, {
- body: c.req.raw.body,
+ body: bodyToSend,
method: c.req.raw.method,
signal,
headers,
diff --git a/internal/api/src/routes/agent-request.test.ts b/internal/api/src/routes/agent-request.test.ts
index c6352ff6..682dd9ef 100644
--- a/internal/api/src/routes/agent-request.test.ts
+++ b/internal/api/src/routes/agent-request.test.ts
@@ -1,18 +1,30 @@
import { describe, expect, test } from "bun:test";
+import type { Agent } from "@blink.so/database/schema";
import type { Server } from "bun";
import { serve } from "../test";
interface SetupAgentOptions {
name: string;
- handler: (req: Request) => Response;
+ handler: (req: Request) => Response | Promise;
+ /** If provided, sets up slack_verification on the agent */
+ slackVerification?: {
+ signingSecret: string;
+ botToken: string;
+ /** Custom expiresAt timestamp (defaults to 24h from now) */
+ expiresAt?: string;
+ };
}
interface SetupAgentResult extends Disposable {
+ /** The agent ID */
+ agentId: string;
/** Subpath webhook URL (/api/webhook/:id) - cookies are stripped */
webhookUrl: string;
getWebhookUrl: (subpath?: string) => string;
/** Fetch via subdomain (uses Host header) - cookies pass through */
fetchSubdomain: (path?: string, init?: RequestInit) => Promise;
+ /** Get the current agent from the database */
+ getAgent: () => Promise;
}
async function setupAgent(
@@ -74,6 +86,24 @@ async function setupAgent(
if (!agent.request_url) throw new Error("No webhook route");
const db = await bindings.database();
+
+ // Set up slack verification if provided
+ if (options.slackVerification) {
+ const now = new Date();
+ const defaultExpiresAt = new Date(
+ now.getTime() + 24 * 60 * 60 * 1000
+ ).toISOString();
+ await db.updateAgent({
+ id: agent.id,
+ slack_verification: {
+ signingSecret: options.slackVerification.signingSecret,
+ botToken: options.slackVerification.botToken,
+ startedAt: now.toISOString(),
+ expiresAt: options.slackVerification.expiresAt ?? defaultExpiresAt,
+ },
+ });
+ }
+
const target = await db.selectAgentDeploymentTargetByName(
agent.id,
"production"
@@ -85,6 +115,7 @@ async function setupAgent(
const subdomainHost = `${requestId}.${parsedApiUrl.host}`;
return {
+ agentId: agent.id,
webhookUrl: `${apiUrl}/api/webhook/${target.request_id}`,
getWebhookUrl: (subpath?: string) =>
`${apiUrl}/api/webhook/${target.request_id}${subpath || ""}`,
@@ -95,6 +126,7 @@ async function setupAgent(
headers.set("host", subdomainHost);
return fetch(`${apiUrl}${path || "/"}`, { ...init, headers });
},
+ getAgent: () => db.selectAgentByID(agent.id),
[Symbol.dispose]: () => mockServer?.stop(),
};
}
@@ -378,6 +410,57 @@ describe("webhook requests (/api/webhook/:id)", () => {
});
});
+describe("Slack request Content-Length handling", () => {
+ test("recalculates Content-Length when body is read as text for Slack verification", async () => {
+ // Body with multi-byte UTF-8 characters
+ // "Hello 世界" is 6 ASCII chars (6 bytes) + 1 space (1 byte) + 2 Chinese chars (6 bytes) = 13 bytes
+ const bodyWithMultiByteChars = JSON.stringify({
+ type: "event_callback",
+ event: { type: "message", text: "Hello 世界" },
+ });
+ const expectedByteLength = new TextEncoder().encode(
+ bodyWithMultiByteChars
+ ).length;
+
+ let receivedContentLength: string | null | undefined;
+ let receivedBodyLength: number | undefined;
+
+ using agent = await setupAgent({
+ name: "slack-content-length",
+ handler: async (req) => {
+ receivedContentLength = req.headers.get("content-length");
+ const body = await req.text();
+ receivedBodyLength = new TextEncoder().encode(body).length;
+ return new Response("OK");
+ },
+ slackVerification: {
+ signingSecret: "test-secret",
+ botToken: "xoxb-test-token",
+ },
+ });
+
+ // Send request with a Content-Length that would be wrong if we used string length
+ // instead of byte length (string length is different from byte length for multi-byte chars)
+ const response = await fetch(agent.getWebhookUrl("/slack"), {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ // Intentionally set a wrong Content-Length to verify it gets corrected
+ "content-length": String(bodyWithMultiByteChars.length),
+ },
+ body: bodyWithMultiByteChars,
+ });
+
+ // The request should succeed (we won't have valid Slack signature, but that's OK for this test)
+ // What we're testing is that the Content-Length was recalculated correctly
+ expect(response.status).toBe(200);
+
+ // Verify the Content-Length header received by the agent matches actual byte length
+ expect(receivedContentLength).toBe(String(expectedByteLength));
+ expect(receivedBodyLength).toBe(expectedByteLength);
+ });
+});
+
describe("subdomain requests", () => {
test("basic request", async () => {
using agent = await setupAgent({
@@ -474,3 +557,110 @@ describe("subdomain requests", () => {
expect(response.headers.get("vary")).toBe("Origin, Accept-Encoding");
});
});
+
+describe("Slack verification expiration", () => {
+ test("clears expired slack_verification and skips verification processing", async () => {
+ // Set expiresAt to 1 hour ago (already expired)
+ const expiredAt = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString();
+
+ let handlerCalled = false;
+
+ using agent = await setupAgent({
+ name: "slack-expired",
+ handler: () => {
+ handlerCalled = true;
+ return new Response("OK");
+ },
+ slackVerification: {
+ signingSecret: "test-secret",
+ botToken: "xoxb-test-token",
+ expiresAt: expiredAt,
+ },
+ });
+
+ // Verify slack_verification is set before request
+ const beforeAgent = await agent.getAgent();
+ expect(beforeAgent?.slack_verification).not.toBeNull();
+
+ // Make a request to /slack path
+ const response = await fetch(agent.getWebhookUrl("/slack"), {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ type: "event_callback", event: { type: "test" } }),
+ });
+
+ expect(response.status).toBe(200);
+ expect(handlerCalled).toBe(true);
+
+ // Verify slack_verification was cleared
+ const afterAgent = await agent.getAgent();
+ expect(afterAgent?.slack_verification).toBeNull();
+ });
+
+ test("does not clear non-expired slack_verification", async () => {
+ // Set expiresAt to 1 hour from now (not expired yet)
+ const futureExpiresAt = new Date(
+ Date.now() + 1 * 60 * 60 * 1000
+ ).toISOString();
+
+ using agent = await setupAgent({
+ name: "slack-not-expired",
+ handler: () => new Response("OK"),
+ slackVerification: {
+ signingSecret: "test-secret",
+ botToken: "xoxb-test-token",
+ expiresAt: futureExpiresAt,
+ },
+ });
+
+ // Verify slack_verification is set before request
+ const beforeAgent = await agent.getAgent();
+ expect(beforeAgent?.slack_verification).not.toBeNull();
+
+ // Make a request to /slack path
+ const response = await fetch(agent.getWebhookUrl("/slack"), {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ type: "event_callback", event: { type: "test" } }),
+ });
+
+ expect(response.status).toBe(200);
+
+ // Verify slack_verification was NOT cleared (still active)
+ const afterAgent = await agent.getAgent();
+ expect(afterAgent?.slack_verification).not.toBeNull();
+ });
+
+ test("does not run verification logic for expired slack_verification on non-slack paths", async () => {
+ // Set expiresAt to 1 hour ago (already expired)
+ const expiredAt = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString();
+
+ using agent = await setupAgent({
+ name: "slack-expired-non-slack-path",
+ handler: () => new Response("OK"),
+ slackVerification: {
+ signingSecret: "test-secret",
+ botToken: "xoxb-test-token",
+ expiresAt: expiredAt,
+ },
+ });
+
+ // Verify slack_verification is set before request
+ const beforeAgent = await agent.getAgent();
+ expect(beforeAgent?.slack_verification).not.toBeNull();
+
+ // Make a request to a non-slack path (e.g., /github)
+ const response = await fetch(agent.getWebhookUrl("/github"), {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ action: "push" }),
+ });
+
+ expect(response.status).toBe(200);
+
+ // slack_verification should NOT be cleared for non-slack paths
+ // (expiration check only runs for /slack requests)
+ const afterAgent = await agent.getAgent();
+ expect(afterAgent?.slack_verification).not.toBeNull();
+ });
+});
diff --git a/internal/api/src/routes/agents/agents.client.ts b/internal/api/src/routes/agents/agents.client.ts
index 2d75ecb3..cb16b749 100644
--- a/internal/api/src/routes/agents/agents.client.ts
+++ b/internal/api/src/routes/agents/agents.client.ts
@@ -19,6 +19,8 @@ import AgentEnv, { schemaCreateAgentEnv } from "./env.client";
import AgentLogs from "./logs.client";
import AgentMembers from "./members.client";
import AgentRuns from "./runs.client";
+import AgentSetupGitHub from "./setup-github.client";
+import AgentSetupSlack from "./setup-slack.client";
import AgentSteps from "./steps.client";
import AgentTraces from "./traces.client";
@@ -28,6 +30,82 @@ export const schemaAgentVisibility = z.enum([
"organization",
]);
+export const schemaOnboardingStep = z.enum([
+ "welcome",
+ "llm-api-keys",
+ "github-setup",
+ "slack-setup",
+ "web-search",
+ "deploying",
+ "success",
+]);
+
+export const schemaOnboardingState = z.object({
+ currentStep: schemaOnboardingStep,
+ finished: z.boolean().optional(),
+ github: z
+ .object({
+ appName: z.string(),
+ appUrl: z.string(),
+ installUrl: z.string(),
+ // Credentials (values)
+ appId: z.number().optional(),
+ clientId: z.string().optional(),
+ clientSecret: z.string().optional(),
+ webhookSecret: z.string().optional(),
+ privateKey: z.string().optional(),
+ // Env var names used
+ envVars: z
+ .object({
+ appId: z.string(),
+ clientId: z.string(),
+ clientSecret: z.string(),
+ webhookSecret: z.string(),
+ privateKey: z.string(),
+ })
+ .optional(),
+ })
+ .optional(),
+ slack: z
+ .object({
+ botToken: z.string(),
+ signingSecret: z.string(),
+ envVars: z
+ .object({
+ botToken: z.string(),
+ signingSecret: z.string(),
+ })
+ .optional(),
+ })
+ .optional(),
+ llm: z
+ .object({
+ provider: z.enum(["anthropic", "openai", "vercel"]).optional(),
+ apiKey: z.string().optional(),
+ envVar: z.string().optional(),
+ })
+ .optional(),
+ webSearch: z
+ .object({
+ provider: z.enum(["exa"]).optional(),
+ apiKey: z.string().optional(),
+ envVar: z.string().optional(),
+ })
+ .optional(),
+});
+
+export type OnboardingState = z.infer;
+export type OnboardingStep = z.infer;
+
+export const schemaIntegrationsState = z.object({
+ llm: z.boolean().optional(),
+ github: z.boolean().optional(),
+ slack: z.boolean().optional(),
+ webSearch: z.boolean().optional(),
+});
+
+export type IntegrationsState = z.infer;
+
export const schemaCreateAgentRequest = z.object({
organization_id: z.uuid(),
name: z.string().regex(nameFormat),
@@ -48,6 +126,9 @@ export const schemaCreateAgentRequest = z.object({
// Optional: Specify the request_id for the production deployment target.
// This is useful for setting up webhooks before the agent is fully deployed.
request_id: z.uuid().optional(),
+
+ // Optional: Initialize agent with onboarding state
+ onboarding_state: schemaOnboardingState.optional(),
});
export type CreateAgentRequest = z.infer;
@@ -70,6 +151,8 @@ export const schemaAgent = z.object({
.describe("The URL for the agent requests. Only visible to owners."),
chat_expire_ttl: z.number().int().positive().nullable(),
user_permission: z.enum(["read", "write", "admin"]).optional(),
+ onboarding_state: schemaOnboardingState.nullable(),
+ integrations_state: schemaIntegrationsState.nullable(),
});
export const schemaUpdateAgentRequest = z.object({
@@ -165,6 +248,8 @@ export default class Agents {
public readonly logs: AgentLogs;
public readonly traces: AgentTraces;
public readonly members: AgentMembers;
+ public readonly setupGitHub: AgentSetupGitHub;
+ public readonly setupSlack: AgentSetupSlack;
public constructor(client: Client) {
this.client = client;
@@ -175,6 +260,8 @@ export default class Agents {
this.logs = new AgentLogs(client);
this.traces = new AgentTraces(client);
this.members = new AgentMembers(client);
+ this.setupGitHub = new AgentSetupGitHub(client);
+ this.setupSlack = new AgentSetupSlack(client);
}
/**
@@ -351,6 +438,59 @@ export default class Agents {
await assertResponseStatus(resp, 200);
return resp.json();
}
+
+ /**
+ * Update onboarding state for an agent.
+ *
+ * @param id - The id of the agent.
+ * @param state - The partial onboarding state to merge.
+ * @returns The updated agent.
+ */
+ public async updateOnboarding(
+ id: string,
+ state: Partial
+ ): Promise {
+ const resp = await this.client.request(
+ "PATCH",
+ `/api/agents/${id}/onboarding`,
+ JSON.stringify(state)
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
+
+ /**
+ * Clear onboarding state for an agent (mark onboarding as complete).
+ *
+ * @param id - The id of the agent.
+ */
+ public async clearOnboarding(id: string): Promise {
+ const resp = await this.client.request(
+ "DELETE",
+ `/api/agents/${id}/onboarding`
+ );
+ await assertResponseStatus(resp, 204);
+ }
+
+ /**
+ * Update integrations state for an agent.
+ *
+ * @param id - The id of the agent.
+ * @param state - The partial integrations state to merge.
+ * @returns The updated agent.
+ */
+ public async updateIntegrationsState(
+ id: string,
+ state: Partial
+ ): Promise {
+ const resp = await this.client.request(
+ "PATCH",
+ `/api/agents/${id}/integrations`,
+ JSON.stringify(state)
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
}
export * from "./deployments.client";
diff --git a/internal/api/src/routes/agents/agents.server.ts b/internal/api/src/routes/agents/agents.server.ts
index 9a5b86d7..3891f801 100644
--- a/internal/api/src/routes/agents/agents.server.ts
+++ b/internal/api/src/routes/agents/agents.server.ts
@@ -28,6 +28,8 @@ import mountLogs from "./logs.server";
import mountAgentsMe from "./me/me.server";
import mountAgentMembers from "./members.server";
import mountRuns from "./runs.server";
+import mountSetupGitHub from "./setup-github.server";
+import mountSetupSlack from "./setup-slack.server";
import mountSteps from "./steps.server";
import mountTraces from "./traces.server";
@@ -52,6 +54,7 @@ export default function mountAgents(app: Hono<{ Bindings: Bindings }>) {
description: req.description,
visibility: req.visibility ?? "organization",
chat_expire_ttl: req.chat_expire_ttl,
+ onboarding_state: req.onboarding_state,
});
// Grant admin permission to the creator
@@ -258,6 +261,87 @@ export default function mountAgents(app: Hono<{ Bindings: Bindings }>) {
return c.body(null, 204);
});
+ // Update onboarding state for an agent.
+ app.patch(
+ "/:agent_id/onboarding",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("write"),
+ async (c) => {
+ const agent = c.get("agent");
+ const db = await c.env.database();
+ const body = await c.req.json();
+
+ // Merge the new state with existing state
+ const currentState = agent.onboarding_state ?? { currentStep: "welcome" };
+ const newState = { ...currentState, ...body };
+
+ const updated = await db.updateAgent({
+ id: agent.id,
+ onboarding_state: newState,
+ });
+ return c.json(
+ convert.agent(
+ updated,
+ await createAgentRequestURL(c, updated),
+ await getAgentUserPermission(c, updated)
+ )
+ );
+ }
+ );
+
+ // Clear onboarding state for an agent (mark onboarding complete).
+ app.delete(
+ "/:agent_id/onboarding",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("write"),
+ async (c) => {
+ const agent = c.get("agent");
+ const db = await c.env.database();
+
+ if (agent.onboarding_state) {
+ await db.updateAgent({
+ id: agent.id,
+ onboarding_state: {
+ finished: true,
+ currentStep: agent.onboarding_state.currentStep,
+ },
+ });
+ }
+ return c.body(null, 204);
+ }
+ );
+
+ // Update integrations state for an agent.
+ app.patch(
+ "/:agent_id/integrations",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("write"),
+ async (c) => {
+ const agent = c.get("agent");
+ const db = await c.env.database();
+ const body = await c.req.json();
+
+ // Merge the new state with existing state
+ const currentState = agent.integrations_state ?? {};
+ const newState = { ...currentState, ...body };
+
+ const updated = await db.updateAgent({
+ id: agent.id,
+ integrations_state: newState,
+ });
+ return c.json(
+ convert.agent(
+ updated,
+ await createAgentRequestURL(c, updated),
+ await getAgentUserPermission(c, updated)
+ )
+ );
+ }
+ );
+
// Delete an agent.
app.delete(
"/:agent_id",
@@ -417,6 +501,8 @@ export default function mountAgents(app: Hono<{ Bindings: Bindings }>) {
mountLogs(app.basePath("/:agent_id/logs"));
mountTraces(app.basePath("/:agent_id/traces"));
mountAgentMembers(app.basePath("/:agent_id/members"));
+ mountSetupGitHub(app.basePath("/:agent_id/setup/github"));
+ mountSetupSlack(app.basePath("/:agent_id/setup/slack"));
// This is special - just for the agent invocation API.
// We don't like to do this, but we do because this API
diff --git a/internal/api/src/routes/agents/setup-github.client.ts b/internal/api/src/routes/agents/setup-github.client.ts
new file mode 100644
index 00000000..f1666b13
--- /dev/null
+++ b/internal/api/src/routes/agents/setup-github.client.ts
@@ -0,0 +1,151 @@
+import { z } from "zod";
+import type Client from "../../client.browser";
+import { assertResponseStatus } from "../../client-helper";
+
+// GitHub App data returned from GitHub after creation
+export const schemaGitHubAppData = z.object({
+ id: z.number(),
+ client_id: z.string(),
+ client_secret: z.string(),
+ webhook_secret: z.string(),
+ pem: z.string(),
+ name: z.string(),
+ html_url: z.string(),
+ slug: z.string(),
+});
+
+export type GitHubAppData = z.infer;
+
+// Start creation request/response
+export const schemaStartGitHubAppCreationRequest = z.object({
+ name: z.string().min(1).max(34),
+ organization: z.string().optional(),
+});
+
+export type StartGitHubAppCreationRequest = z.infer<
+ typeof schemaStartGitHubAppCreationRequest
+>;
+
+export const schemaStartGitHubAppCreationResponse = z.object({
+ manifest: z.string(),
+ github_url: z.string(),
+ session_id: z.string(),
+});
+
+export type StartGitHubAppCreationResponse = z.infer<
+ typeof schemaStartGitHubAppCreationResponse
+>;
+
+// GitHub credentials returned when status is completed
+// These should be saved as env vars by the client
+export const schemaGitHubAppCredentials = z.object({
+ app_id: z.number(),
+ client_id: z.string(),
+ client_secret: z.string(),
+ webhook_secret: z.string(),
+ private_key: z.string(), // base64-encoded PEM
+});
+
+export type GitHubAppCredentials = z.infer;
+
+// Creation status response
+// Status flow: pending -> app_created -> completed
+// - pending: waiting for user to create app on GitHub
+// - app_created: app created, waiting for user to install it
+// - completed: app created and installed
+// - failed/expired: error states
+export const schemaGitHubAppCreationStatusResponse = z.object({
+ status: z.enum(["pending", "app_created", "completed", "failed", "expired"]),
+ error: z.string().optional(),
+ app_data: z
+ .object({
+ id: z.number(),
+ name: z.string(),
+ html_url: z.string(),
+ slug: z.string(),
+ })
+ .optional(),
+ // Credentials are only included when status is "completed"
+ credentials: schemaGitHubAppCredentials.optional(),
+});
+
+export type GitHubAppCreationStatusResponse = z.infer<
+ typeof schemaGitHubAppCreationStatusResponse
+>;
+
+// Complete creation request
+export const schemaCompleteGitHubAppCreationRequest = z.object({
+ session_id: z.string(),
+});
+
+export type CompleteGitHubAppCreationRequest = z.infer<
+ typeof schemaCompleteGitHubAppCreationRequest
+>;
+
+export const schemaCompleteGitHubAppCreationResponse = z.object({
+ success: z.boolean(),
+ app_name: z.string().optional(),
+ app_url: z.string().optional(),
+ install_url: z.string().optional(),
+});
+
+export type CompleteGitHubAppCreationResponse = z.infer<
+ typeof schemaCompleteGitHubAppCreationResponse
+>;
+
+export default class AgentSetupGitHub {
+ private readonly client: Client;
+
+ public constructor(client: Client) {
+ this.client = client;
+ }
+
+ /**
+ * Start GitHub App creation for an agent.
+ * Returns a URL to redirect the user to GitHub for app creation.
+ */
+ public async startCreation(
+ agentId: string,
+ request: StartGitHubAppCreationRequest
+ ): Promise {
+ const resp = await this.client.request(
+ "POST",
+ `/api/agents/${agentId}/setup/github/start-creation`,
+ JSON.stringify(request)
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
+
+ /**
+ * Get the current creation status.
+ * Poll this endpoint to check if the GitHub callback has been received.
+ */
+ public async getCreationStatus(
+ agentId: string,
+ sessionId: string
+ ): Promise {
+ const resp = await this.client.request(
+ "GET",
+ `/api/agents/${agentId}/setup/github/creation-status/${sessionId}`
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
+
+ /**
+ * Complete GitHub App creation.
+ */
+ public async completeCreation(
+ agentId: string,
+ request: CompleteGitHubAppCreationRequest
+ ): Promise {
+ const resp = await this.client.request(
+ "POST",
+ `/api/agents/${agentId}/setup/github/complete-creation`,
+ JSON.stringify(request)
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
+}
diff --git a/internal/api/src/routes/agents/setup-github.server.ts b/internal/api/src/routes/agents/setup-github.server.ts
new file mode 100644
index 00000000..3a0ce9c9
--- /dev/null
+++ b/internal/api/src/routes/agents/setup-github.server.ts
@@ -0,0 +1,493 @@
+import type { Hono } from "hono";
+import { HTTPException } from "hono/http-exception";
+import { validator } from "hono/validator";
+
+import {
+ withAgentPermission,
+ withAgentURLParam,
+ withAuth,
+} from "../../middleware";
+import type { Bindings } from "../../server";
+import { createWebhookURL } from "../../server-helper";
+import {
+ type CompleteGitHubAppCreationResponse,
+ type GitHubAppCreationStatusResponse,
+ type StartGitHubAppCreationResponse,
+ schemaCompleteGitHubAppCreationRequest,
+ schemaGitHubAppData,
+ schemaStartGitHubAppCreationRequest,
+} from "./setup-github.client";
+
+// 24 hour expiry for GitHub App creation sessions
+const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Create the GitHub App manifest for the manifest flow.
+ */
+function createGitHubAppManifest(
+ name: string,
+ webhookUrl: string,
+ callbackUrl: string,
+ setupUrl: string
+) {
+ return {
+ name,
+ url: "https://github.com/coder/blink",
+ description: "A Blink agent for GitHub",
+ public: false,
+ redirect_url: callbackUrl,
+ setup_url: setupUrl,
+ setup_on_update: true,
+ hook_attributes: {
+ url: webhookUrl,
+ active: true,
+ },
+ default_events: [
+ "issues",
+ "issue_comment",
+ "pull_request",
+ "pull_request_review",
+ "pull_request_review_comment",
+ "push",
+ ],
+ default_permissions: {
+ contents: "write",
+ issues: "write",
+ pull_requests: "write",
+ metadata: "read",
+ },
+ };
+}
+
+export default function mountSetupGitHub(
+ app: Hono<{
+ Bindings: Bindings;
+ }>
+) {
+ // Start GitHub App creation
+ app.post(
+ "/start-creation",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("write"),
+ validator("json", (value) => {
+ return schemaStartGitHubAppCreationRequest.parse(value);
+ }),
+ async (c) => {
+ const agent = c.get("agent");
+ const req = c.req.valid("json");
+ const db = await c.env.database();
+
+ // Get the agent's production deployment target for webhook URL
+ const target = await db.selectAgentDeploymentTargetByName(
+ agent.id,
+ "production"
+ );
+ if (!target) {
+ return c.json({ error: "No deployment target found" }, 400);
+ }
+
+ const webhookUrl = createWebhookURL(c.env, target.request_id, "github");
+ const sessionId = crypto.randomUUID();
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() + SESSION_EXPIRY_MS);
+
+ const apiOrigin = c.env.accessUrl.origin;
+ // Build the callback URL - this is where GitHub will redirect after app creation
+ const callbackUrl = `${apiOrigin}/api/agents/${agent.id}/setup/github/callback?session_id=${sessionId}`;
+ // Build the setup URL - this is where GitHub will redirect after app installation
+ const setupUrl = `${apiOrigin}/api/agents/${agent.id}/setup/github/setup-complete?session_id=${sessionId}`;
+
+ const manifest = createGitHubAppManifest(
+ req.name,
+ webhookUrl,
+ callbackUrl,
+ setupUrl
+ );
+
+ await db.updateAgent({
+ id: agent.id,
+ github_app_setup: {
+ sessionId,
+ startedAt: now.toISOString(),
+ expiresAt: expiresAt.toISOString(),
+ status: "pending",
+ },
+ });
+
+ // Return the manifest and GitHub URL for the frontend to submit
+ const githubUrl = req.organization
+ ? `https://github.com/organizations/${req.organization}/settings/apps/new`
+ : `https://github.com/settings/apps/new`;
+
+ const response: StartGitHubAppCreationResponse = {
+ manifest: JSON.stringify(manifest),
+ github_url: githubUrl,
+ session_id: sessionId,
+ };
+ return c.json(response);
+ }
+ );
+
+ // GitHub callback - receives the code after app creation
+ // This endpoint is PUBLIC (no auth) because GitHub redirects the user's browser here
+ // Security is provided by validating the session_id
+ app.get("/callback", async (c) => {
+ const agentId = c.req.param("agent_id");
+ if (!agentId) {
+ return c.html(createCallbackHtml("error", "Agent ID is required"));
+ }
+
+ const db = await c.env.database();
+ const agent = await db.selectAgentByID(agentId);
+ if (!agent) {
+ return c.html(createCallbackHtml("error", "Agent not found"));
+ }
+
+ const sessionId = c.req.query("session_id");
+ const code = c.req.query("code");
+
+ // Validate session - this provides security for this public endpoint
+ const setup = agent.github_app_setup;
+ if (!setup || setup.sessionId !== sessionId) {
+ return c.html(
+ createCallbackHtml(
+ "error",
+ "Invalid or expired session. Please restart the GitHub App setup."
+ )
+ );
+ }
+
+ // Check expiry
+ if (new Date() > new Date(setup.expiresAt)) {
+ await db.updateAgent({
+ id: agent.id,
+ github_app_setup: {
+ ...setup,
+ status: "failed",
+ error: "Session expired",
+ },
+ });
+ return c.html(
+ createCallbackHtml(
+ "error",
+ "Session expired. Please restart the GitHub App setup."
+ )
+ );
+ }
+
+ if (!code) {
+ await db.updateAgent({
+ id: agent.id,
+ github_app_setup: {
+ ...setup,
+ status: "failed",
+ error: "No code received from GitHub",
+ },
+ });
+ return c.html(
+ createCallbackHtml(
+ "error",
+ "No authorization code received from GitHub."
+ )
+ );
+ }
+
+ try {
+ // Exchange the code for credentials
+ const res = await fetch(
+ `https://api.github.com/app-manifests/${code}/conversions`,
+ {
+ method: "POST",
+ headers: {
+ Accept: "application/vnd.github+json",
+ "User-Agent": `Blink-Server/${c.env.serverVersion}`,
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ }
+ );
+
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(
+ `GitHub API error: ${res.status} ${res.statusText}${errorText ? ` - ${errorText}` : ""}`
+ );
+ }
+
+ const rawData = await res.json();
+ const data = schemaGitHubAppData.parse(rawData);
+
+ // Store the app data in the session (status stays "pending" until installation)
+ await db.updateAgent({
+ id: agent.id,
+ github_app_setup: {
+ ...setup,
+ status: "app_created",
+ appData: {
+ id: data.id,
+ clientId: data.client_id,
+ clientSecret: data.client_secret,
+ webhookSecret: data.webhook_secret,
+ pem: data.pem,
+ name: data.name,
+ htmlUrl: data.html_url,
+ slug: data.slug,
+ },
+ },
+ });
+
+ // Redirect to the app's installation page
+ const installUrl = `${data.html_url}/installations/new`;
+ return c.redirect(installUrl);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+
+ await db.updateAgent({
+ id: agent.id,
+ github_app_setup: {
+ ...setup,
+ status: "failed",
+ error: errorMessage,
+ },
+ });
+
+ return c.html(
+ createCallbackHtml(
+ "error",
+ `Failed to create GitHub App: ${errorMessage}`
+ )
+ );
+ }
+ });
+
+ // Setup complete - this is the Setup URL that GitHub redirects to after app installation
+ // This endpoint is PUBLIC (no auth) because GitHub redirects the user's browser here
+ app.get("/setup-complete", async (c) => {
+ const agentId = c.req.param("agent_id");
+ if (!agentId) {
+ return c.html(
+ createCallbackHtml("error", "Agent ID is required", undefined)
+ );
+ }
+
+ const db = await c.env.database();
+ const agent = await db.selectAgentByID(agentId);
+ if (!agent) {
+ return c.html(createCallbackHtml("error", "Agent not found", undefined));
+ }
+
+ const sessionId = c.req.query("session_id");
+ const installationId = c.req.query("installation_id");
+ const setup = agent.github_app_setup;
+
+ // Check if there's an active setup session
+ const hasActiveSession = setup && setup.sessionId === sessionId;
+
+ if (hasActiveSession && setup.appData) {
+ // Update the setup with installation info if provided
+ if (installationId) {
+ await db.updateAgent({
+ id: agent.id,
+ github_app_setup: {
+ ...setup,
+ status: "completed",
+ installationId,
+ },
+ });
+ } else if (setup.status === "app_created") {
+ // Mark as completed even without installation_id (user might have installed)
+ await db.updateAgent({
+ id: agent.id,
+ github_app_setup: {
+ ...setup,
+ status: "completed",
+ },
+ });
+ }
+
+ return c.html(
+ createCallbackHtml(
+ "success",
+ `GitHub App "${setup.appData.name}" has been installed! Return to the setup wizard to continue.`,
+ true
+ )
+ );
+ }
+
+ // No active session - just show a generic success message
+ return c.html(
+ createCallbackHtml("success", "GitHub App installation complete!", false)
+ );
+ });
+
+ // Get creation status
+ app.get(
+ "/creation-status/:session_id",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("read"),
+ async (c) => {
+ const agent = c.get("agent");
+ const sessionId = c.req.param("session_id");
+ const setup = agent.github_app_setup;
+
+ if (!setup || setup.sessionId !== sessionId) {
+ return c.json({ status: "expired" as const });
+ }
+
+ // Check expiry for pending states
+ if (
+ (setup.status === "pending" || setup.status === "app_created") &&
+ new Date() > new Date(setup.expiresAt)
+ ) {
+ return c.json({ status: "expired" as const });
+ }
+
+ const response: GitHubAppCreationStatusResponse = {
+ status: setup.status,
+ error: setup.error,
+ app_data: setup.appData
+ ? {
+ id: setup.appData.id,
+ name: setup.appData.name,
+ html_url: setup.appData.htmlUrl,
+ slug: setup.appData.slug,
+ }
+ : undefined,
+ // Include full credentials only when status is completed
+ // so the client can save them as env vars
+ credentials:
+ setup.status === "completed" && setup.appData
+ ? {
+ app_id: setup.appData.id,
+ client_id: setup.appData.clientId,
+ client_secret: setup.appData.clientSecret,
+ webhook_secret: setup.appData.webhookSecret,
+ private_key: btoa(setup.appData.pem),
+ }
+ : undefined,
+ };
+ return c.json(response);
+ }
+ );
+
+ // Complete creation - clear setup state
+ app.post(
+ "/complete-creation",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("write"),
+ validator("json", (value) => {
+ return schemaCompleteGitHubAppCreationRequest.parse(value);
+ }),
+ async (c) => {
+ const agent = c.get("agent");
+ const req = c.req.valid("json");
+ const db = await c.env.database();
+
+ const setup = agent.github_app_setup;
+ if (!setup || setup.sessionId !== req.session_id) {
+ throw new HTTPException(400, {
+ message: "Invalid or expired session",
+ });
+ }
+
+ if (setup.status !== "completed" || !setup.appData) {
+ throw new HTTPException(400, {
+ message: "GitHub App creation not completed",
+ });
+ }
+
+ // Clear setup state
+ await db.updateAgent({
+ id: agent.id,
+ github_app_setup: null,
+ });
+
+ const response: CompleteGitHubAppCreationResponse = {
+ success: true,
+ app_name: setup.appData.name,
+ app_url: setup.appData.htmlUrl,
+ install_url: `${setup.appData.htmlUrl}/installations/new`,
+ };
+ return c.json(response);
+ }
+ );
+}
+
+/**
+ * Create HTML page for the callback response.
+ * @param showWizardHint - If true, shows a hint to return to the setup wizard
+ */
+function createCallbackHtml(
+ status: "success" | "error",
+ message: string,
+ showWizardHint?: boolean
+): string {
+ const isSuccess = status === "success";
+ const bgColor = isSuccess ? "#10b981" : "#ef4444";
+ const icon = isSuccess
+ ? ` `
+ : ` `;
+
+ const wizardHint = showWizardHint
+ ? `You can close this window and return to the setup wizard.
`
+ : "";
+
+ return `
+
+
+
+
+ GitHub App Setup - ${isSuccess ? "Success" : "Error"}
+
+
+
+
+
${icon}
+
${isSuccess ? "Success!" : "Something went wrong"}
+
${message}
+ ${wizardHint}
+
+
+`;
+}
diff --git a/internal/api/src/routes/agents/setup-github.test.ts b/internal/api/src/routes/agents/setup-github.test.ts
new file mode 100644
index 00000000..6e6e66c9
--- /dev/null
+++ b/internal/api/src/routes/agents/setup-github.test.ts
@@ -0,0 +1,327 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { HttpResponse, http } from "msw";
+import { type SetupServerApi, setupServer } from "msw/node";
+import { serve } from "../../test";
+
+let mswServer: SetupServerApi;
+
+beforeEach(() => {
+ mswServer = setupServer();
+ mswServer.listen({ onUnhandledRequest: "bypass" });
+});
+
+afterEach(() => {
+ if (mswServer) {
+ mswServer.close();
+ }
+});
+
+describe("GitHub App Setup", () => {
+ test("start-creation returns manifest and github URL", async () => {
+ const { helpers } = await serve({
+ bindings: {
+ accessUrl: new URL("https://test.blink.so"),
+ },
+ });
+ const { client } = await helpers.createUser();
+ const org = await client.organizations.create({
+ name: "test-org",
+ });
+ const agent = await client.agents.create({
+ name: "test-agent",
+ organization_id: org.id,
+ });
+
+ const result = await client.agents.setupGitHub.startCreation(agent.id, {
+ name: "my-github-app",
+ });
+
+ expect(result.session_id).toBeDefined();
+ expect(result.github_url).toBe("https://github.com/settings/apps/new");
+ expect(result.manifest).toBeDefined();
+
+ const manifest = JSON.parse(result.manifest);
+ expect(manifest.name).toBe("my-github-app");
+ expect(manifest.public).toBe(false);
+ expect(manifest.default_permissions).toEqual({
+ contents: "write",
+ issues: "write",
+ pull_requests: "write",
+ metadata: "read",
+ });
+ });
+
+ test("start-creation with organization returns organization github URL", async () => {
+ const { helpers } = await serve({
+ bindings: {
+ accessUrl: new URL("https://test.blink.so"),
+ },
+ });
+ const { client } = await helpers.createUser();
+ const org = await client.organizations.create({
+ name: "test-org",
+ });
+ const agent = await client.agents.create({
+ name: "test-agent",
+ organization_id: org.id,
+ });
+
+ const result = await client.agents.setupGitHub.startCreation(agent.id, {
+ name: "my-github-app",
+ organization: "my-gh-org",
+ });
+
+ expect(result.github_url).toBe(
+ "https://github.com/organizations/my-gh-org/settings/apps/new"
+ );
+ });
+
+ test("get-creation-status returns pending for new session", async () => {
+ const { helpers } = await serve({
+ bindings: {
+ accessUrl: new URL("https://test.blink.so"),
+ },
+ });
+ const { client } = await helpers.createUser();
+ const org = await client.organizations.create({
+ name: "test-org",
+ });
+ const agent = await client.agents.create({
+ name: "test-agent",
+ organization_id: org.id,
+ });
+
+ const startResult = await client.agents.setupGitHub.startCreation(
+ agent.id,
+ {
+ name: "my-github-app",
+ }
+ );
+
+ const status = await client.agents.setupGitHub.getCreationStatus(
+ agent.id,
+ startResult.session_id
+ );
+
+ expect(status.status).toBe("pending");
+ expect(status.app_data).toBeUndefined();
+ expect(status.credentials).toBeUndefined();
+ });
+
+ test("get-creation-status returns expired for invalid session", async () => {
+ const { helpers } = await serve({
+ bindings: {
+ accessUrl: new URL("https://test.blink.so"),
+ },
+ });
+ const { client } = await helpers.createUser();
+ const org = await client.organizations.create({
+ name: "test-org",
+ });
+ const agent = await client.agents.create({
+ name: "test-agent",
+ organization_id: org.id,
+ });
+
+ const status = await client.agents.setupGitHub.getCreationStatus(
+ agent.id,
+ "invalid-session-id"
+ );
+
+ expect(status.status).toBe("expired");
+ });
+
+ test("complete-creation fails for pending session", async () => {
+ const { helpers } = await serve({
+ bindings: {
+ accessUrl: new URL("https://test.blink.so"),
+ },
+ });
+ const { client } = await helpers.createUser();
+ const org = await client.organizations.create({
+ name: "test-org",
+ });
+ const agent = await client.agents.create({
+ name: "test-agent",
+ organization_id: org.id,
+ });
+
+ const startResult = await client.agents.setupGitHub.startCreation(
+ agent.id,
+ {
+ name: "my-github-app",
+ }
+ );
+
+ await expect(
+ client.agents.setupGitHub.completeCreation(agent.id, {
+ session_id: startResult.session_id,
+ })
+ ).rejects.toThrow("GitHub App creation not completed");
+ });
+
+ test("get-creation-status returns credentials when completed", async () => {
+ const { helpers, url } = await serve({
+ bindings: {
+ accessUrl: new URL("https://test.blink.so"),
+ },
+ });
+ const { client } = await helpers.createUser();
+ const org = await client.organizations.create({
+ name: "test-org",
+ });
+ const agent = await client.agents.create({
+ name: "test-agent",
+ organization_id: org.id,
+ });
+
+ const startResult = await client.agents.setupGitHub.startCreation(
+ agent.id,
+ {
+ name: "my-github-app",
+ }
+ );
+
+ // Mock the GitHub API endpoint that exchanges the code for app credentials
+ mswServer.use(
+ http.post(
+ "https://api.github.com/app-manifests/:code/conversions",
+ () => {
+ return HttpResponse.json({
+ id: 12345,
+ client_id: "Iv1.abc123",
+ client_secret: "secret123",
+ webhook_secret: "webhook-secret",
+ pem: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
+ name: "my-github-app",
+ html_url: "https://github.com/apps/my-github-app",
+ slug: "my-github-app",
+ });
+ }
+ )
+ );
+
+ // Call the callback endpoint as GitHub would (redirecting with a code)
+ const callbackRes = await fetch(
+ `${url}/api/agents/${agent.id}/setup/github/callback?session_id=${startResult.session_id}&code=mock-code`,
+ { redirect: "manual" }
+ );
+ // Callback should redirect to the app's installation page
+ expect(callbackRes.status).toBe(302);
+ expect(callbackRes.headers.get("Location")).toBe(
+ "https://github.com/apps/my-github-app/installations/new"
+ );
+
+ // Call the setup-complete endpoint as GitHub would after app installation
+ const setupCompleteRes = await fetch(
+ `${url}/api/agents/${agent.id}/setup/github/setup-complete?session_id=${startResult.session_id}`,
+ { redirect: "manual" }
+ );
+ expect(setupCompleteRes.status).toBe(200);
+
+ // Now get status should return credentials
+ const status = await client.agents.setupGitHub.getCreationStatus(
+ agent.id,
+ startResult.session_id
+ );
+
+ expect(status.status).toBe("completed");
+ expect(status.app_data).toEqual({
+ id: 12345,
+ name: "my-github-app",
+ html_url: "https://github.com/apps/my-github-app",
+ slug: "my-github-app",
+ });
+ expect(status.credentials).toBeDefined();
+ if (!status.credentials) {
+ throw new Error("Credentials should be defined");
+ }
+ expect(status.credentials.app_id).toBe(12345);
+ expect(status.credentials.client_id).toBe("Iv1.abc123");
+ expect(status.credentials.client_secret).toBe("secret123");
+ expect(status.credentials.webhook_secret).toBe("webhook-secret");
+ // Private key should be base64 encoded
+ expect(status.credentials.private_key).toBe(
+ btoa(
+ "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----"
+ )
+ );
+ });
+
+ test("complete-creation clears setup state when completed", async () => {
+ const { helpers, url } = await serve({
+ bindings: {
+ accessUrl: new URL("https://test.blink.so"),
+ },
+ });
+ const { client } = await helpers.createUser();
+ const org = await client.organizations.create({
+ name: "test-org",
+ });
+ const agent = await client.agents.create({
+ name: "test-agent",
+ organization_id: org.id,
+ });
+
+ const startResult = await client.agents.setupGitHub.startCreation(
+ agent.id,
+ {
+ name: "my-github-app",
+ }
+ );
+
+ // Mock the GitHub API endpoint that exchanges the code for app credentials
+ mswServer.use(
+ http.post(
+ "https://api.github.com/app-manifests/:code/conversions",
+ () => {
+ return HttpResponse.json({
+ id: 12345,
+ client_id: "Iv1.abc123",
+ client_secret: "secret123",
+ webhook_secret: "webhook-secret",
+ pem: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
+ name: "my-github-app",
+ html_url: "https://github.com/apps/my-github-app",
+ slug: "my-github-app",
+ });
+ }
+ )
+ );
+
+ // Call the callback endpoint as GitHub would (redirecting with a code)
+ await fetch(
+ `${url}/api/agents/${agent.id}/setup/github/callback?session_id=${startResult.session_id}&code=mock-code`,
+ { redirect: "manual" }
+ );
+
+ // Call the setup-complete endpoint as GitHub would after app installation
+ await fetch(
+ `${url}/api/agents/${agent.id}/setup/github/setup-complete?session_id=${startResult.session_id}`,
+ { redirect: "manual" }
+ );
+
+ // Complete the creation
+ const result = await client.agents.setupGitHub.completeCreation(agent.id, {
+ session_id: startResult.session_id,
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.app_name).toBe("my-github-app");
+ expect(result.app_url).toBe("https://github.com/apps/my-github-app");
+ expect(result.install_url).toBe(
+ "https://github.com/apps/my-github-app/installations/new"
+ );
+
+ // Verify setup state is cleared
+ const status = await client.agents.setupGitHub.getCreationStatus(
+ agent.id,
+ startResult.session_id
+ );
+ expect(status.status).toBe("expired");
+
+ // Verify no env vars were created (that's now the client's responsibility)
+ const envVars = await client.agents.env.list({ agent_id: agent.id });
+ expect(envVars.length).toBe(0);
+ });
+});
diff --git a/internal/api/src/routes/agents/setup-slack.client.ts b/internal/api/src/routes/agents/setup-slack.client.ts
new file mode 100644
index 00000000..f7176e38
--- /dev/null
+++ b/internal/api/src/routes/agents/setup-slack.client.ts
@@ -0,0 +1,194 @@
+import { z } from "zod";
+import type Client from "../../client.browser";
+import { assertResponseStatus } from "../../client-helper";
+
+// Slack verification state stored on agent
+export const schemaSlackVerification = z
+ .object({
+ signingSecret: z.string(),
+ botToken: z.string(),
+ startedAt: z.string(),
+ lastEventAt: z.string().optional(),
+ dmReceivedAt: z.string().optional(),
+ dmChannel: z.string().optional(),
+ signatureFailedAt: z.string().optional(),
+ })
+ .nullable();
+
+export type SlackVerification = z.infer;
+
+// Start verification request/response
+export const schemaStartSlackVerificationRequest = z.object({
+ signing_secret: z.string().min(1),
+ bot_token: z.string().min(1),
+});
+
+export type StartSlackVerificationRequest = z.infer<
+ typeof schemaStartSlackVerificationRequest
+>;
+
+export const schemaStartSlackVerificationResponse = z.object({
+ webhook_url: z.string(),
+});
+
+export type StartSlackVerificationResponse = z.infer<
+ typeof schemaStartSlackVerificationResponse
+>;
+
+// Verification status response
+export const schemaSlackVerificationStatusResponse = z.object({
+ active: z.boolean(),
+ started_at: z.string().optional(),
+ last_event_at: z.string().optional(),
+ dm_received: z.boolean(),
+ dm_channel: z.string().optional(),
+ signature_failed: z.boolean(),
+ signature_failed_at: z.string().optional(),
+});
+
+export type SlackVerificationStatusResponse = z.infer<
+ typeof schemaSlackVerificationStatusResponse
+>;
+
+// Complete verification request
+export const schemaCompleteSlackVerificationRequest = z.object({
+ bot_token: z.string().min(1),
+ signing_secret: z.string().min(1),
+});
+
+export type CompleteSlackVerificationRequest = z.infer<
+ typeof schemaCompleteSlackVerificationRequest
+>;
+
+export const schemaCompleteSlackVerificationResponse = z.object({
+ success: z.boolean(),
+ bot_name: z.string().optional(),
+});
+
+export type CompleteSlackVerificationResponse = z.infer<
+ typeof schemaCompleteSlackVerificationResponse
+>;
+
+// Webhook URL response
+export const schemaSlackWebhookUrlResponse = z.object({
+ webhook_url: z.string(),
+});
+
+export type SlackWebhookUrlResponse = z.infer<
+ typeof schemaSlackWebhookUrlResponse
+>;
+
+// Validate token request/response
+export const schemaValidateSlackTokenRequest = z.object({
+ botToken: z.string(),
+});
+
+export type ValidateSlackTokenRequest = z.infer<
+ typeof schemaValidateSlackTokenRequest
+>;
+
+export const schemaValidateSlackTokenResponse = z.object({
+ valid: z.boolean(),
+ error: z.string().optional(),
+});
+
+export type ValidateSlackTokenResponse = z.infer<
+ typeof schemaValidateSlackTokenResponse
+>;
+
+export default class AgentSetupSlack {
+ private readonly client: Client;
+
+ public constructor(client: Client) {
+ this.client = client;
+ }
+
+ /**
+ * Get the webhook URL for Slack integration.
+ * This doesn't require any credentials and can be called before setup.
+ */
+ public async getWebhookUrl(
+ agentId: string
+ ): Promise {
+ const resp = await this.client.request(
+ "GET",
+ `/api/agents/${agentId}/setup/slack/webhook-url`
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
+
+ /**
+ * Validate a Slack bot token by calling Slack's auth.test API.
+ */
+ public async validateToken(
+ agentId: string,
+ request: ValidateSlackTokenRequest
+ ): Promise {
+ const resp = await this.client.request(
+ "POST",
+ `/api/agents/${agentId}/setup/slack/validate-token`,
+ JSON.stringify(request)
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
+
+ /**
+ * Start Slack verification for an agent.
+ * This sets up the webhook to listen for Slack events.
+ */
+ public async startVerification(
+ agentId: string,
+ request: StartSlackVerificationRequest
+ ): Promise {
+ const resp = await this.client.request(
+ "POST",
+ `/api/agents/${agentId}/setup/slack/start-verification`,
+ JSON.stringify(request)
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
+
+ /**
+ * Get the current verification status.
+ */
+ public async getVerificationStatus(
+ agentId: string
+ ): Promise {
+ const resp = await this.client.request(
+ "GET",
+ `/api/agents/${agentId}/setup/slack/verification-status`
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
+
+ /**
+ * Complete Slack verification and save credentials.
+ */
+ public async completeVerification(
+ agentId: string,
+ request: CompleteSlackVerificationRequest
+ ): Promise {
+ const resp = await this.client.request(
+ "POST",
+ `/api/agents/${agentId}/setup/slack/complete-verification`,
+ JSON.stringify(request)
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
+
+ /**
+ * Cancel ongoing Slack verification.
+ */
+ public async cancelVerification(agentId: string): Promise {
+ const resp = await this.client.request(
+ "POST",
+ `/api/agents/${agentId}/setup/slack/cancel-verification`
+ );
+ await assertResponseStatus(resp, 204);
+ }
+}
diff --git a/internal/api/src/routes/agents/setup-slack.server.ts b/internal/api/src/routes/agents/setup-slack.server.ts
new file mode 100644
index 00000000..1aa35ce9
--- /dev/null
+++ b/internal/api/src/routes/agents/setup-slack.server.ts
@@ -0,0 +1,222 @@
+import type { Hono } from "hono";
+import { validator } from "hono/validator";
+
+import {
+ withAgentPermission,
+ withAgentURLParam,
+ withAuth,
+} from "../../middleware";
+import type { Bindings } from "../../server";
+import { createWebhookURL } from "../../server-helper";
+import {
+ type CompleteSlackVerificationResponse,
+ type SlackVerificationStatusResponse,
+ type StartSlackVerificationResponse,
+ schemaCompleteSlackVerificationRequest,
+ schemaStartSlackVerificationRequest,
+ schemaValidateSlackTokenRequest,
+ type ValidateSlackTokenResponse,
+} from "./setup-slack.client";
+
+/**
+ * Verify Slack bot token by calling auth.test API.
+ */
+async function verifySlackBotToken(
+ botToken: string
+): Promise<{ valid: boolean; error?: string; botName?: string }> {
+ try {
+ const resp = await fetch("https://slack.com/api/auth.test", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${botToken}`,
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ });
+ const data = (await resp.json()) as {
+ ok: boolean;
+ error?: string;
+ user?: string;
+ bot_id?: string;
+ };
+ if (!data.ok) {
+ return { valid: false, error: data.error || "Invalid token" };
+ }
+ return { valid: true, botName: data.user };
+ } catch (error) {
+ return {
+ valid: false,
+ error: error instanceof Error ? error.message : "Verification failed",
+ };
+ }
+}
+
+export default function mountSetupSlack(
+ app: Hono<{
+ Bindings: Bindings;
+ }>
+) {
+ // Get webhook URL (no credentials required)
+ app.get(
+ "/webhook-url",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("read"),
+ async (c) => {
+ const agent = c.get("agent");
+ const db = await c.env.database();
+
+ // Get the agent's production deployment target for webhook URL
+ const target = await db.selectAgentDeploymentTargetByName(
+ agent.id,
+ "production"
+ );
+ if (!target) {
+ return c.json({ error: "No deployment target found" }, 400);
+ }
+
+ const webhookUrl = createWebhookURL(c.env, target.request_id, "slack");
+ return c.json({ webhook_url: webhookUrl });
+ }
+ );
+
+ // Validate Slack bot token
+ app.post(
+ "/validate-token",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("read"),
+ validator("json", (value) => {
+ return schemaValidateSlackTokenRequest.parse(value);
+ }),
+ async (c) => {
+ const req = c.req.valid("json");
+ const result = await verifySlackBotToken(req.botToken);
+ const response: ValidateSlackTokenResponse = {
+ valid: result.valid,
+ error: result.error,
+ };
+ return c.json(response);
+ }
+ );
+
+ // Start Slack verification
+ app.post(
+ "/start-verification",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("write"),
+ validator("json", (value) => {
+ return schemaStartSlackVerificationRequest.parse(value);
+ }),
+ async (c) => {
+ const agent = c.get("agent");
+ const req = c.req.valid("json");
+ const db = await c.env.database();
+
+ // Get the agent's production deployment target for webhook URL
+ const target = await db.selectAgentDeploymentTargetByName(
+ agent.id,
+ "production"
+ );
+ if (!target) {
+ return c.json({ error: "No deployment target found" }, 400);
+ }
+
+ const webhookUrl = createWebhookURL(c.env, target.request_id, "slack");
+
+ // Store verification state (expires after 24 hours)
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000);
+ await db.updateAgent({
+ id: agent.id,
+ slack_verification: {
+ signingSecret: req.signing_secret,
+ botToken: req.bot_token,
+ startedAt: now.toISOString(),
+ expiresAt: expiresAt.toISOString(),
+ },
+ });
+
+ const response: StartSlackVerificationResponse = {
+ webhook_url: webhookUrl,
+ };
+ return c.json(response);
+ }
+ );
+
+ // Get verification status
+ app.get(
+ "/verification-status",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("read"),
+ async (c) => {
+ const agent = c.get("agent");
+ const verification = agent.slack_verification;
+
+ const response: SlackVerificationStatusResponse = {
+ active: verification !== null,
+ started_at: verification?.startedAt,
+ last_event_at: verification?.lastEventAt,
+ dm_received: verification?.dmReceivedAt !== undefined,
+ dm_channel: verification?.dmChannel,
+ signature_failed: verification?.signatureFailedAt !== undefined,
+ signature_failed_at: verification?.signatureFailedAt,
+ };
+ return c.json(response);
+ }
+ );
+
+ // Complete verification
+ app.post(
+ "/complete-verification",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("write"),
+ validator("json", (value) => {
+ return schemaCompleteSlackVerificationRequest.parse(value);
+ }),
+ async (c) => {
+ const agent = c.get("agent");
+ const req = c.req.valid("json");
+ const db = await c.env.database();
+
+ // Verify the bot token
+ const verification = await verifySlackBotToken(req.bot_token);
+ if (!verification.valid) {
+ return c.json({ success: false, error: verification.error }, 400);
+ }
+
+ await db.updateAgent({
+ id: agent.id,
+ slack_verification: null,
+ });
+
+ const response: CompleteSlackVerificationResponse = {
+ success: true,
+ bot_name: verification.botName,
+ };
+ return c.json(response);
+ }
+ );
+
+ // Cancel verification
+ app.post(
+ "/cancel-verification",
+ withAuth,
+ withAgentURLParam,
+ withAgentPermission("write"),
+ async (c) => {
+ const agent = c.get("agent");
+ const db = await c.env.database();
+
+ // Clear verification state
+ await db.updateAgent({
+ id: agent.id,
+ slack_verification: null,
+ });
+
+ return c.body(null, 204);
+ }
+ );
+}
diff --git a/internal/api/src/routes/agents/setup-slack.test.ts b/internal/api/src/routes/agents/setup-slack.test.ts
new file mode 100644
index 00000000..52c4d759
--- /dev/null
+++ b/internal/api/src/routes/agents/setup-slack.test.ts
@@ -0,0 +1,477 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { createHmac } from "node:crypto";
+import { HttpResponse, http } from "msw";
+import { type SetupServerApi, setupServer } from "msw/node";
+import type Client from "../../client.node";
+import { serve as originalServe } from "../../test";
+
+const serve = () => {
+ return originalServe({
+ bindings: {
+ accessUrl: new URL("https://test.blink.so"),
+ matchRequestHost: undefined,
+ createRequestURL: undefined,
+ },
+ });
+};
+
+type ServeResult = Awaited>;
+
+/**
+ * Compute a valid Slack signature for testing.
+ */
+function computeSlackSignature(
+ signingSecret: string,
+ timestamp: string,
+ body: string
+): string {
+ const hmac = createHmac("sha256", signingSecret);
+ const sigBasestring = `v0:${timestamp}:${body}`;
+ hmac.update(sigBasestring);
+ return `v0=${hmac.digest("hex")}`;
+}
+
+/**
+ * Helper to create test fixtures (user, org, agent).
+ */
+async function setupAgent(helpers: ServeResult["helpers"]) {
+ const { client } = await helpers.createUser();
+ const org = await client.organizations.create({ name: "test-org" });
+ const agent = await client.agents.create({
+ name: "test-agent",
+ organization_id: org.id,
+ });
+ return { client, org, agent };
+}
+
+/**
+ * Helper to send a Slack webhook request with proper signature.
+ */
+async function sendSlackWebhook(
+ baseUrl: string,
+ webhookPath: string,
+ signingSecret: string,
+ payload: object
+) {
+ const body = JSON.stringify(payload);
+ const timestamp = Math.floor(Date.now() / 1000).toString();
+ const signature = computeSlackSignature(signingSecret, timestamp, body);
+
+ return fetch(`${baseUrl}${webhookPath}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-slack-signature": signature,
+ "x-slack-request-timestamp": timestamp,
+ },
+ body,
+ });
+}
+
+/**
+ * Helper to start verification and get webhook path.
+ */
+async function startVerificationAndGetWebhookPath(
+ client: Client,
+ agentId: string,
+ signingSecret: string,
+ botToken = "xoxb-test-bot-token"
+) {
+ const result = await client.agents.setupSlack.startVerification(agentId, {
+ signing_secret: signingSecret,
+ bot_token: botToken,
+ });
+ const webhookPath = new URL(result.webhook_url).pathname;
+ return { webhookUrl: result.webhook_url, webhookPath };
+}
+
+/**
+ * Helper to mock Slack auth.test API response.
+ */
+function mockSlackAuthTest(
+ mswServer: SetupServerApi,
+ response: { ok: boolean; user?: string; bot_id?: string; error?: string }
+) {
+ mswServer.use(
+ http.post("https://slack.com/api/auth.test", () => {
+ return HttpResponse.json(response);
+ })
+ );
+}
+
+let mswServer: SetupServerApi;
+
+beforeEach(() => {
+ mswServer = setupServer();
+ mswServer.listen({ onUnhandledRequest: "bypass" });
+});
+
+afterEach(() => {
+ if (mswServer) {
+ mswServer.close();
+ }
+});
+
+describe("Slack Setup", () => {
+ describe("GET /webhook-url", () => {
+ test("returns webhook URL when deployment target exists", async () => {
+ const { helpers } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+
+ const result = await client.agents.setupSlack.getWebhookUrl(agent.id);
+
+ expect(result.webhook_url).toMatch(
+ /^https:\/\/test\.blink\.so\/api\/webhook\/[a-z0-9-]+\/slack$/
+ );
+ });
+ });
+
+ describe("POST /start-verification", () => {
+ test("stores verification state and returns webhook URL", async () => {
+ const { helpers } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+
+ const result = await client.agents.setupSlack.startVerification(
+ agent.id,
+ { signing_secret: "test-signing-secret", bot_token: "xoxb-test-token" }
+ );
+
+ expect(result.webhook_url).toMatch(
+ /^https:\/\/test\.blink\.so\/api\/webhook\/[a-z0-9-]+\/slack$/
+ );
+
+ const status = await client.agents.setupSlack.getVerificationStatus(
+ agent.id
+ );
+ expect(status.active).toBe(true);
+ expect(status.started_at).toBeDefined();
+ });
+ });
+
+ describe("GET /verification-status", () => {
+ test("returns active: false when no verification in progress", async () => {
+ const { helpers } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+
+ const status = await client.agents.setupSlack.getVerificationStatus(
+ agent.id
+ );
+
+ expect(status.active).toBe(false);
+ expect(status.started_at).toBeUndefined();
+ expect(status.dm_received).toBe(false);
+ expect(status.signature_failed).toBe(false);
+ });
+
+ test("returns correct status fields when verification active", async () => {
+ const { helpers } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+
+ await client.agents.setupSlack.startVerification(agent.id, {
+ signing_secret: "test-signing-secret",
+ bot_token: "xoxb-test-bot-token",
+ });
+
+ const status = await client.agents.setupSlack.getVerificationStatus(
+ agent.id
+ );
+
+ expect(status.active).toBe(true);
+ expect(status.started_at).toBeDefined();
+ expect(status.dm_received).toBe(false);
+ expect(status.signature_failed).toBe(false);
+ });
+ });
+
+ describe("POST /complete-verification", () => {
+ test("verifies token and returns bot name", async () => {
+ const { helpers } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+
+ mockSlackAuthTest(mswServer, {
+ ok: true,
+ user: "test-bot",
+ bot_id: "B123",
+ });
+
+ const result = await client.agents.setupSlack.completeVerification(
+ agent.id,
+ {
+ bot_token: "xoxb-test-bot-token",
+ signing_secret: "test-signing-secret",
+ }
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.bot_name).toBe("test-bot");
+ });
+
+ test("returns 400 when bot token verification fails", async () => {
+ const { helpers } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+
+ mockSlackAuthTest(mswServer, { ok: false, error: "invalid_auth" });
+
+ await expect(
+ client.agents.setupSlack.completeVerification(agent.id, {
+ bot_token: "xoxb-invalid-token",
+ signing_secret: "test-signing-secret",
+ })
+ ).rejects.toThrow();
+ });
+
+ test("clears verification state after completion", async () => {
+ const { helpers } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+
+ await client.agents.setupSlack.startVerification(agent.id, {
+ signing_secret: "test-signing-secret",
+ bot_token: "xoxb-test-bot-token",
+ });
+
+ let status = await client.agents.setupSlack.getVerificationStatus(
+ agent.id
+ );
+ expect(status.active).toBe(true);
+
+ mockSlackAuthTest(mswServer, {
+ ok: true,
+ user: "test-bot",
+ bot_id: "B123",
+ });
+
+ await client.agents.setupSlack.completeVerification(agent.id, {
+ bot_token: "xoxb-test-bot-token",
+ signing_secret: "test-signing-secret",
+ });
+
+ status = await client.agents.setupSlack.getVerificationStatus(agent.id);
+ expect(status.active).toBe(false);
+ });
+ });
+
+ describe("POST /cancel-verification", () => {
+ test("clears verification state and returns 204", async () => {
+ const { helpers } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+
+ await client.agents.setupSlack.startVerification(agent.id, {
+ signing_secret: "test-signing-secret",
+ bot_token: "xoxb-test-bot-token",
+ });
+
+ let status = await client.agents.setupSlack.getVerificationStatus(
+ agent.id
+ );
+ expect(status.active).toBe(true);
+
+ await client.agents.setupSlack.cancelVerification(agent.id);
+
+ status = await client.agents.setupSlack.getVerificationStatus(agent.id);
+ expect(status.active).toBe(false);
+ });
+ });
+
+ describe("integration flow", () => {
+ test("full workflow: start -> poll status -> complete", async () => {
+ const { helpers } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+
+ // Step 1: Get webhook URL
+ const webhookResult = await client.agents.setupSlack.getWebhookUrl(
+ agent.id
+ );
+ expect(webhookResult.webhook_url).toBeDefined();
+
+ // Step 2: Start verification
+ const startResult = await client.agents.setupSlack.startVerification(
+ agent.id,
+ { signing_secret: "test-signing-secret", bot_token: "xoxb-test-token" }
+ );
+ expect(startResult.webhook_url).toBe(webhookResult.webhook_url);
+
+ // Step 3: Poll status (verification should be active)
+ const status = await client.agents.setupSlack.getVerificationStatus(
+ agent.id
+ );
+ expect(status.active).toBe(true);
+ expect(status.dm_received).toBe(false);
+
+ // Step 4: Complete verification
+ mockSlackAuthTest(mswServer, {
+ ok: true,
+ user: "integration-test-bot",
+ bot_id: "B789",
+ });
+
+ const completeResult =
+ await client.agents.setupSlack.completeVerification(agent.id, {
+ bot_token: "xoxb-test-token",
+ signing_secret: "test-signing-secret",
+ });
+ expect(completeResult.success).toBe(true);
+ expect(completeResult.bot_name).toBe("integration-test-bot");
+
+ const finalStatus = await client.agents.setupSlack.getVerificationStatus(
+ agent.id
+ );
+ expect(finalStatus.active).toBe(false);
+ });
+ });
+
+ describe("Slack webhook during verification", () => {
+ test("handles URL verification challenge", async () => {
+ const { helpers, url } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+ const signingSecret = "test-signing-secret-for-webhook";
+
+ const { webhookPath } = await startVerificationAndGetWebhookPath(
+ client,
+ agent.id,
+ signingSecret
+ );
+
+ const response = await sendSlackWebhook(
+ url.toString(),
+ webhookPath,
+ signingSecret,
+ {
+ type: "url_verification",
+ challenge: "test-challenge-token-12345",
+ }
+ );
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ challenge: "test-challenge-token-12345",
+ });
+
+ const status = await client.agents.setupSlack.getVerificationStatus(
+ agent.id
+ );
+ expect(status.active).toBe(true);
+ expect(status.last_event_at).toBeDefined();
+ });
+
+ test("tracks DM receipt and updates verification status", async () => {
+ const { helpers, url } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+ const signingSecret = "test-signing-secret-for-dm";
+
+ // Mock the chat.postMessage endpoint (for the congratulations message)
+ mswServer.use(
+ http.post("https://slack.com/api/chat.postMessage", () => {
+ return HttpResponse.json({ ok: true });
+ })
+ );
+
+ const { webhookPath } = await startVerificationAndGetWebhookPath(
+ client,
+ agent.id,
+ signingSecret
+ );
+
+ const response = await sendSlackWebhook(
+ url.toString(),
+ webhookPath,
+ signingSecret,
+ {
+ type: "event_callback",
+ event: {
+ type: "message",
+ channel_type: "im",
+ channel: "D12345678",
+ user: "U12345678",
+ text: "Hello bot!",
+ ts: "1234567890.123456",
+ },
+ }
+ );
+
+ expect(response.status).toBe(200);
+
+ const status = await client.agents.setupSlack.getVerificationStatus(
+ agent.id
+ );
+ expect(status.active).toBe(true);
+ expect(status.dm_received).toBe(true);
+ expect(status.dm_channel).toBe("D12345678");
+ expect(status.last_event_at).toBeDefined();
+ });
+
+ test("tracks signature failure when signature is invalid", async () => {
+ const { helpers, url } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+
+ const { webhookPath } = await startVerificationAndGetWebhookPath(
+ client,
+ agent.id,
+ "correct-signing-secret"
+ );
+
+ // Send with WRONG signing secret
+ const response = await sendSlackWebhook(
+ url.toString(),
+ webhookPath,
+ "wrong-signing-secret",
+ {
+ type: "event_callback",
+ event: { type: "message", channel_type: "im", channel: "D12345678" },
+ }
+ );
+
+ expect(response.status).toBe(200);
+
+ const status = await client.agents.setupSlack.getVerificationStatus(
+ agent.id
+ );
+ expect(status.active).toBe(true);
+ expect(status.signature_failed).toBe(true);
+ expect(status.signature_failed_at).toBeDefined();
+ });
+
+ test("sends congratulations message on first DM", async () => {
+ const { helpers, url } = await serve();
+ const { client, agent } = await setupAgent(helpers);
+ const signingSecret = "test-signing-secret-congrats";
+
+ // Track the chat.postMessage call
+ let postMessageCalled = false;
+ let postMessageBody: unknown = null;
+ mswServer.use(
+ http.post(
+ "https://slack.com/api/chat.postMessage",
+ async ({ request }) => {
+ postMessageCalled = true;
+ postMessageBody = await request.json();
+ return HttpResponse.json({ ok: true });
+ }
+ )
+ );
+
+ const { webhookPath } = await startVerificationAndGetWebhookPath(
+ client,
+ agent.id,
+ signingSecret
+ );
+
+ await sendSlackWebhook(url.toString(), webhookPath, signingSecret, {
+ type: "event_callback",
+ event: {
+ type: "message",
+ channel_type: "im",
+ channel: "D99999999",
+ user: "U12345678",
+ text: "Test message",
+ ts: "1234567890.999999",
+ },
+ });
+
+ expect(postMessageCalled).toBe(true);
+ expect(postMessageBody).toMatchObject({
+ channel: "D99999999",
+ thread_ts: "1234567890.999999",
+ });
+ });
+ });
+});
diff --git a/internal/api/src/routes/agents/slack-webhook.ts b/internal/api/src/routes/agents/slack-webhook.ts
new file mode 100644
index 00000000..33515aa3
--- /dev/null
+++ b/internal/api/src/routes/agents/slack-webhook.ts
@@ -0,0 +1,238 @@
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+import type { Bindings } from "../../server";
+import type { AgentRequestRouting } from "../agent-request.server";
+
+type SlackVerification = {
+ signingSecret: string;
+ botToken: string;
+ startedAt: string;
+ expiresAt: string;
+ lastEventAt?: string;
+ dmReceivedAt?: string;
+ dmChannel?: string;
+ signatureFailedAt?: string;
+};
+
+/**
+ * Check if a request is for the Slack webhook path.
+ */
+export function isSlackRequest(
+ routing: AgentRequestRouting,
+ pathname: string
+): boolean {
+ return (
+ (routing.mode === "webhook" && routing.subpath === "/slack") ||
+ (routing.mode === "subdomain" && pathname === "/slack")
+ );
+}
+
+function isSlackVerificationExpired(expiresAt: string): boolean {
+ return Date.now() > new Date(expiresAt).getTime();
+}
+
+/**
+ * Handle Slack webhook requests during verification flow.
+ * Returns a Response if the request should be handled here, or null to continue to the agent.
+ * When continuing to the agent, bodyText contains the already-read request body.
+ */
+export async function handleSlackWebhook(
+ db: Awaited>,
+ agent: { id: string; slack_verification: SlackVerification | null },
+ request: Request,
+ hasDeployment: boolean
+): Promise<
+ | { response: Response; bodyText?: undefined }
+ | { response: null; bodyText?: string }
+> {
+ const slackVerification = agent.slack_verification;
+
+ if (!slackVerification) {
+ return { response: null };
+ }
+
+ // Check if verification has expired - if so, clear it and continue to agent
+ if (isSlackVerificationExpired(slackVerification.expiresAt)) {
+ await db.updateAgent({
+ id: agent.id,
+ slack_verification: null,
+ });
+ return { response: null };
+ }
+
+ // Read the body for verification processing
+ const bodyText = await request.text();
+
+ const result = await processSlackVerificationTracking(
+ db,
+ { id: agent.id, slack_verification: slackVerification },
+ bodyText,
+ request.headers.get("x-slack-signature") ?? undefined,
+ request.headers.get("x-slack-request-timestamp") ?? undefined
+ );
+
+ // URL verification challenge must be responded to immediately
+ if (result.challengeResponse) {
+ return {
+ response: Response.json({ challenge: result.challengeResponse }),
+ };
+ }
+
+ // Invalid signature - acknowledge but don't process further
+ if (!result.signatureValid) {
+ return { response: Response.json({ ok: true }) };
+ }
+
+ // No deployment - we've tracked the event, just acknowledge
+ if (!hasDeployment) {
+ return { response: Response.json({ ok: true }) };
+ }
+
+ // Continue to forward to agent
+ return { response: null, bodyText };
+}
+
+/**
+ * Verify Slack request signature using HMAC-SHA256.
+ */
+export function verifySlackSignature(
+ signingSecret: string,
+ timestamp: string,
+ body: string,
+ signature: string
+): boolean {
+ const time = Math.floor(Date.now() / 1000);
+ const requestTimestamp = Number.parseInt(timestamp, 10);
+
+ // Request is older than 5 minutes - reject to prevent replay attacks
+ if (Math.abs(time - requestTimestamp) > 60 * 5) {
+ return false;
+ }
+
+ const hmac = createHmac("sha256", signingSecret);
+ const sigBasestring = `v0:${timestamp}:${body}`;
+ hmac.update(sigBasestring);
+ const mySignature = `v0=${hmac.digest("hex")}`;
+
+ try {
+ return timingSafeEqual(Buffer.from(mySignature), Buffer.from(signature));
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Process Slack verification tracking without blocking the request flow.
+ * Returns tracking results so the caller can decide how to proceed.
+ */
+async function processSlackVerificationTracking(
+ db: Awaited>,
+ agent: {
+ id: string;
+ slack_verification: SlackVerification;
+ },
+ body: string,
+ slackSignature: string | undefined,
+ slackTimestamp: string | undefined
+): Promise<{
+ signatureValid: boolean;
+ challengeResponse?: string;
+}> {
+ const verification = agent.slack_verification;
+
+ // Verify Slack signature if headers are present
+ if (slackSignature && slackTimestamp) {
+ if (
+ !verifySlackSignature(
+ verification.signingSecret,
+ slackTimestamp,
+ body,
+ slackSignature
+ )
+ ) {
+ // Signature verification failed - record in database
+ await db.updateAgent({
+ id: agent.id,
+ slack_verification: {
+ ...verification,
+ signatureFailedAt: new Date().toISOString(),
+ },
+ });
+ return { signatureValid: false };
+ }
+ }
+
+ // Parse the payload
+ let payload: {
+ type?: string;
+ challenge?: string;
+ event?: {
+ type?: string;
+ channel_type?: string;
+ channel?: string;
+ bot_id?: string;
+ ts?: string;
+ };
+ };
+
+ try {
+ payload = JSON.parse(body);
+ } catch {
+ // Can't parse - treat as invalid but not a security issue
+ return { signatureValid: true };
+ }
+
+ // Handle Slack URL verification challenge
+ if (payload.type === "url_verification" && payload.challenge) {
+ // Update lastEventAt since we received a valid event
+ await db.updateAgent({
+ id: agent.id,
+ slack_verification: {
+ ...verification,
+ lastEventAt: new Date().toISOString(),
+ },
+ });
+ return { signatureValid: true, challengeResponse: payload.challenge };
+ }
+
+ // Track if we received a DM
+ const isDM =
+ payload.event?.type === "message" &&
+ payload.event.channel_type === "im" &&
+ !payload.event.bot_id; // Ignore bot's own messages
+
+ // If this is a DM and we haven't already recorded one, send a response to Slack
+ if (isDM && !verification.dmReceivedAt && payload.event?.channel) {
+ await fetch("https://slack.com/api/chat.postMessage", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${verification.botToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ channel: payload.event.channel,
+ thread_ts: payload.event.ts,
+ text: "Congrats, your Slack app is set up! You can now go back to the Blink dashboard.",
+ }),
+ }).catch(() => {
+ // Silent fail - user will see status in the UI
+ });
+ }
+
+ const updatedVerification = {
+ ...verification,
+ lastEventAt: new Date().toISOString(),
+ ...(isDM && {
+ dmReceivedAt: new Date().toISOString(),
+ dmChannel: payload.event?.channel,
+ }),
+ };
+
+ await db.updateAgent({
+ id: agent.id,
+ slack_verification: updatedVerification,
+ });
+
+ // Continue to agent - we've tracked the event
+ return { signatureValid: true };
+}
diff --git a/internal/api/src/routes/onboarding/onboarding.client.ts b/internal/api/src/routes/onboarding/onboarding.client.ts
new file mode 100644
index 00000000..4362614d
--- /dev/null
+++ b/internal/api/src/routes/onboarding/onboarding.client.ts
@@ -0,0 +1,51 @@
+import { z } from "zod";
+import type Client from "../../client.browser";
+import { assertResponseStatus } from "../../client-helper";
+
+export const schemaDownloadAgentRequest = z.object({
+ organization_id: z.uuid(),
+});
+
+export type DownloadAgentRequest = z.infer;
+
+export const schemaDownloadAgentFile = z.object({
+ path: z.string(),
+ id: z.uuid(),
+});
+
+export type DownloadAgentFile = z.infer;
+
+export const schemaDownloadAgentResponse = z.object({
+ output_files: z.array(schemaDownloadAgentFile),
+ source_files: z.array(schemaDownloadAgentFile),
+ entrypoint: z.string(),
+ version: z.string().optional(),
+});
+
+export type DownloadAgentResponse = z.infer;
+
+export default class Onboarding {
+ private readonly client: Client;
+
+ public constructor(client: Client) {
+ this.client = client;
+ }
+
+ /**
+ * Download the pre-built onboarding agent from GitHub Releases.
+ *
+ * @param request - The request body containing organization_id.
+ * @returns The file ID and entrypoint of the downloaded agent.
+ */
+ public async downloadAgent(
+ request: DownloadAgentRequest
+ ): Promise {
+ const resp = await this.client.request(
+ "POST",
+ "/api/onboarding/download-agent",
+ JSON.stringify(request)
+ );
+ await assertResponseStatus(resp, 200);
+ return resp.json();
+ }
+}
diff --git a/internal/api/src/routes/onboarding/onboarding.server.ts b/internal/api/src/routes/onboarding/onboarding.server.ts
new file mode 100644
index 00000000..c71ef3be
--- /dev/null
+++ b/internal/api/src/routes/onboarding/onboarding.server.ts
@@ -0,0 +1,147 @@
+import { Readable } from "node:stream";
+import { createGunzip } from "node:zlib";
+import type { Hono } from "hono";
+import { HTTPException } from "hono/http-exception";
+import { validator } from "hono/validator";
+import * as tarStream from "tar-stream";
+import { authorizeOrganization, withAuth } from "../../middleware";
+import type { Bindings } from "../../server";
+import { schemaDownloadAgentRequest } from "./onboarding.client";
+
+export default function mountOnboarding(app: Hono<{ Bindings: Bindings }>) {
+ // Download the onboarding agent bundle from artifacts server
+ app.post(
+ "/download-agent",
+ withAuth,
+ validator("json", (value) => {
+ return schemaDownloadAgentRequest.parse(value);
+ }),
+ async (c) => {
+ const req = c.req.valid("json");
+ await authorizeOrganization(c, req.organization_id);
+
+ const bundleUrl = c.env.ONBOARDING_AGENT_BUNDLE_URL;
+
+ // Fetch the tar.gz bundle (follows redirects by default)
+ const bundleResp = await fetch(bundleUrl, {
+ headers: {
+ "User-Agent": `Blink-Server/${c.env.serverVersion}`,
+ },
+ redirect: "follow",
+ });
+ if (!bundleResp.ok) {
+ throw new HTTPException(502, {
+ message: `Failed to download bundle: ${bundleResp.status}`,
+ });
+ }
+
+ // Extract tar.gz and collect files
+ const files = await extractTarGz(bundleResp);
+
+ // Categorize files
+ const outputFilesToUpload: Array<{ path: string; data: Buffer }> = [];
+ const sourceFilesToUpload: Array<{ path: string; data: Buffer }> = [];
+
+ for (const file of files) {
+ // Skip the bundle.tar.gz if it's inside the archive
+ if (file.path === "bundle.tar.gz") {
+ continue;
+ }
+
+ // Files under .blink/build/ are output files
+ if (file.path.startsWith(".blink/build/")) {
+ const outputPath = file.path.replace(".blink/build/", "");
+ outputFilesToUpload.push({ path: outputPath, data: file.data });
+ }
+ // Skip other .blink/ files (like .blink/config.json)
+ else if (file.path.startsWith(".blink/")) {
+ // Skip
+ }
+ // Everything else is a source file
+ else {
+ sourceFilesToUpload.push({ path: file.path, data: file.data });
+ }
+ }
+
+ // Upload all files in parallel
+ const userId = c.get("user_id");
+
+ const [outputFiles, sourceFiles] = await Promise.all([
+ Promise.all(
+ outputFilesToUpload.map(async (file) => {
+ const { id } = await c.env.files.upload({
+ user_id: userId,
+ organization_id: req.organization_id,
+ file: new File([new Uint8Array(file.data)], file.path),
+ });
+ return { path: file.path, id };
+ })
+ ),
+ Promise.all(
+ sourceFilesToUpload.map(async (file) => {
+ const { id } = await c.env.files.upload({
+ user_id: userId,
+ organization_id: req.organization_id,
+ file: new File([new Uint8Array(file.data)], file.path),
+ });
+ return { path: file.path, id };
+ })
+ ),
+ ]);
+
+ return c.json({
+ output_files: outputFiles,
+ source_files: sourceFiles,
+ entrypoint: "agent.js",
+ });
+ }
+ );
+}
+
+interface ExtractedFile {
+ path: string;
+ data: Buffer;
+}
+
+async function extractTarGz(response: Response): Promise {
+ const files: ExtractedFile[] = [];
+
+ // Get response body as array buffer and convert to Node stream
+ const arrayBuffer = await response.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+ const nodeStream = Readable.from(buffer);
+
+ // Create gunzip and tar extract streams
+ const gunzip = createGunzip();
+ const extract = tarStream.extract();
+
+ return new Promise((resolve, reject) => {
+ extract.on("entry", (header, stream, next) => {
+ // Only process regular files
+ if (header.type !== "file") {
+ stream.resume();
+ next();
+ return;
+ }
+
+ const chunks: Buffer[] = [];
+ stream.on("data", (chunk: Buffer) => chunks.push(chunk));
+ stream.on("end", () => {
+ files.push({
+ path: header.name,
+ data: Buffer.concat(chunks),
+ });
+ next();
+ });
+ stream.on("error", reject);
+ });
+
+ extract.on("finish", () => resolve(files));
+ extract.on("error", reject);
+
+ gunzip.on("error", reject);
+
+ // Pipe: buffer -> gunzip -> tar extract
+ nodeStream.pipe(gunzip).pipe(extract);
+ });
+}
diff --git a/internal/api/src/routes/onboarding/onboarding.test.ts b/internal/api/src/routes/onboarding/onboarding.test.ts
new file mode 100644
index 00000000..3380103b
--- /dev/null
+++ b/internal/api/src/routes/onboarding/onboarding.test.ts
@@ -0,0 +1,230 @@
+import { afterAll, beforeAll, expect, test } from "bun:test";
+import { createGzip } from "node:zlib";
+import * as tarStream from "tar-stream";
+import { serve } from "../../test";
+
+// Helper to create a tar.gz buffer in memory
+async function createMockTarGz(
+ files: Array<{ path: string; content: string }>
+): Promise {
+ const pack = tarStream.pack();
+
+ for (const file of files) {
+ pack.entry({ name: file.path }, file.content);
+ }
+ pack.finalize();
+
+ // Collect tar data
+ const tarChunks: Buffer[] = [];
+ for await (const chunk of pack as unknown as AsyncIterable) {
+ tarChunks.push(chunk);
+ }
+ const tarBuffer = Buffer.concat(tarChunks);
+
+ // Gzip the tar data
+ return new Promise((resolve, reject) => {
+ const gzip = createGzip();
+ const gzipChunks: Buffer[] = [];
+
+ gzip.on("data", (chunk: Buffer) => gzipChunks.push(chunk));
+ gzip.on("end", () => resolve(Buffer.concat(gzipChunks)));
+ gzip.on("error", reject);
+
+ gzip.end(tarBuffer);
+ });
+}
+
+// Mock bundle server
+let mockBundleServer: ReturnType | null = null;
+let mockBundleUrl: string = "";
+
+beforeAll(async () => {
+ // Create mock tar.gz with test files
+ const mockTarGz = await createMockTarGz([
+ // Output files (in .blink/build/)
+ { path: ".blink/build/agent.js", content: 'console.log("Hello agent");' },
+ { path: ".blink/build/package.json", content: '{"type": "module"}' },
+ // Source files
+ { path: "agent.ts", content: 'export const agent = "test";' },
+ { path: "package.json", content: '{"name": "test-agent"}' },
+ { path: "tsconfig.json", content: '{"compilerOptions": {}}' },
+ { path: "README.md", content: "# Test Agent" },
+ // Files to skip
+ { path: ".blink/config.json", content: '{"agentId": "123"}' },
+ ]);
+
+ mockBundleServer = Bun.serve({
+ port: 0,
+ fetch(req) {
+ const url = new URL(req.url);
+
+ // Simulate redirect like artifacts.blink.host does
+ if (url.pathname === "/redirect") {
+ return new Response(null, {
+ status: 302,
+ headers: { Location: `${mockBundleUrl}/bundle.tar.gz` },
+ });
+ }
+
+ if (url.pathname === "/bundle.tar.gz") {
+ return new Response(new Uint8Array(mockTarGz), {
+ headers: { "Content-Type": "application/gzip" },
+ });
+ }
+
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ mockBundleUrl = mockBundleServer.url.toString().replace(/\/$/, "");
+});
+
+afterAll(() => {
+ mockBundleServer?.stop();
+});
+
+test("POST /api/onboarding/download-agent extracts tar.gz and categorizes files", async () => {
+ const { helpers, stop } = await serve({
+ bindings: {
+ ONBOARDING_AGENT_BUNDLE_URL: `${mockBundleUrl}/bundle.tar.gz`,
+ },
+ });
+
+ try {
+ const { client } = await helpers.createUser();
+
+ // Create an organization for the user
+ const org = await client.organizations.create({
+ name: "test-org",
+ });
+
+ // Call the download-agent endpoint
+ const result = await client.onboarding.downloadAgent({
+ organization_id: org.id,
+ });
+
+ // Verify output files (from .blink/build/)
+ expect(result.output_files).toBeArrayOfSize(2);
+ expect(result.output_files.map((f) => f.path).sort()).toEqual([
+ "agent.js",
+ "package.json",
+ ]);
+
+ // Verify source files (everything except .blink/)
+ expect(result.source_files).toBeArrayOfSize(4);
+ expect(result.source_files.map((f) => f.path).sort()).toEqual([
+ "README.md",
+ "agent.ts",
+ "package.json",
+ "tsconfig.json",
+ ]);
+
+ // Verify entrypoint
+ expect(result.entrypoint).toBe("agent.js");
+
+ // Verify all files have valid UUIDs
+ for (const file of [...result.output_files, ...result.source_files]) {
+ expect(file.id).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
+ );
+ }
+ } finally {
+ stop();
+ }
+});
+
+test("POST /api/onboarding/download-agent follows redirects", async () => {
+ const { helpers, stop } = await serve({
+ bindings: {
+ // Use the redirect endpoint
+ ONBOARDING_AGENT_BUNDLE_URL: `${mockBundleUrl}/redirect`,
+ },
+ });
+
+ try {
+ const { client } = await helpers.createUser();
+
+ const org = await client.organizations.create({
+ name: "test-org-redirect",
+ });
+
+ const result = await client.onboarding.downloadAgent({
+ organization_id: org.id,
+ });
+
+ // Should still work after following redirect
+ expect(result.output_files.length).toBeGreaterThan(0);
+ expect(result.source_files.length).toBeGreaterThan(0);
+ expect(result.entrypoint).toBe("agent.js");
+ } finally {
+ stop();
+ }
+});
+
+test("POST /api/onboarding/download-agent returns 502 on download failure", async () => {
+ const { helpers, stop } = await serve({
+ bindings: {
+ ONBOARDING_AGENT_BUNDLE_URL: `${mockBundleUrl}/nonexistent`,
+ },
+ });
+
+ try {
+ const { client } = await helpers.createUser();
+
+ const org = await client.organizations.create({
+ name: "test-org-error",
+ });
+
+ await expect(
+ client.onboarding.downloadAgent({
+ organization_id: org.id,
+ })
+ ).rejects.toThrow();
+ } finally {
+ stop();
+ }
+});
+
+test("POST /api/onboarding/download-agent includes server version in User-Agent", async () => {
+ const captured: { userAgent?: string } = {};
+
+ // Create a mock server that captures the User-Agent header
+ const mockTarGz = await createMockTarGz([
+ { path: ".blink/build/agent.js", content: "test" },
+ { path: "agent.ts", content: "test" },
+ ]);
+
+ const userAgentServer = Bun.serve({
+ port: 0,
+ fetch(req) {
+ captured.userAgent = req.headers.get("User-Agent") ?? undefined;
+ return new Response(new Uint8Array(mockTarGz), {
+ headers: { "Content-Type": "application/gzip" },
+ });
+ },
+ });
+
+ const { helpers, stop } = await serve({
+ bindings: {
+ ONBOARDING_AGENT_BUNDLE_URL: `${userAgentServer.url}bundle.tar.gz`,
+ serverVersion: "custom-version",
+ },
+ });
+
+ try {
+ const { client } = await helpers.createUser();
+
+ const org = await client.organizations.create({
+ name: "test-org-user-agent",
+ });
+
+ await client.onboarding.downloadAgent({
+ organization_id: org.id,
+ });
+
+ expect(captured.userAgent).toBe("Blink-Server/custom-version");
+ } finally {
+ stop();
+ userAgentServer.stop();
+ }
+});
diff --git a/internal/api/src/server-helper.ts b/internal/api/src/server-helper.ts
index db852357..91fd5e34 100644
--- a/internal/api/src/server-helper.ts
+++ b/internal/api/src/server-helper.ts
@@ -1,5 +1,6 @@
import { DrizzleQueryError } from "drizzle-orm/errors";
import postgres from "postgres";
+import type { Bindings } from "./server";
export const isUniqueConstraintError = (
err: unknown,
@@ -51,3 +52,38 @@ export const detectRequestLocation = (request: Request): string | undefined => {
}
return parts.length ? parts.join(", ") : undefined;
};
+
+/**
+ * Construct a webhook URL for an agent.
+ * Uses subdomain routing if matchRequestHost is configured, otherwise uses path-based routing.
+ *
+ * @param env - The bindings containing createRequestURL, matchRequestHost, and accessUrl
+ * @param requestId - The deployment target's request ID
+ * @param path - The webhook path (e.g., "github", "slack", or "/github")
+ * @returns The webhook URL
+ */
+export const createWebhookURL = (
+ env: Bindings,
+ requestId: string,
+ path: string
+): string => {
+ // Normalize path to always start with /
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
+
+ if (env.createRequestURL) {
+ const baseUrl = env.createRequestURL(requestId);
+ return new URL(normalizedPath, baseUrl).toString();
+ }
+
+ // Use subdomain routing if configured
+ if (env.matchRequestHost) {
+ // Construct subdomain URL from accessUrl: https://{request_id}.{host}/{path}
+ const baseUrl = new URL(env.accessUrl);
+ baseUrl.host = `${requestId}.${baseUrl.host}`;
+ baseUrl.pathname = normalizedPath;
+ return baseUrl.toString();
+ }
+
+ // Path-based webhook mode: /api/webhook/{request_id}/{path}
+ return `${env.accessUrl.origin}/api/webhook/${requestId}${normalizedPath}`;
+};
diff --git a/internal/api/src/server.ts b/internal/api/src/server.ts
index deb7c217..698850cd 100644
--- a/internal/api/src/server.ts
+++ b/internal/api/src/server.ts
@@ -18,6 +18,7 @@ import mountDevhook from "./routes/devhook.server";
import mountFiles from "./routes/files.server";
import mountInvites from "./routes/invites.server";
import mountMessages from "./routes/messages.server";
+import mountOnboarding from "./routes/onboarding/onboarding.server";
import mountOrganizations from "./routes/organizations/organizations.server";
import type { OtelSpan } from "./routes/otlp/convert";
import mountOtlp from "./routes/otlp/otlp.server";
@@ -213,6 +214,11 @@ export interface Bindings {
* Pathname will not be respected - /api is used.
*/
readonly apiBaseURL: URL;
+ /**
+ * accessUrl is the public URL used for external access (e.g., webhooks).
+ * This may differ from apiBaseURL when using tunnels or proxies.
+ */
+ readonly accessUrl: URL;
readonly matchRequestHost?: (host: string) => string | undefined;
readonly createRequestURL?: (id: string) => URL;
@@ -220,6 +226,7 @@ export interface Bindings {
readonly NODE_ENV: string;
readonly AI_GATEWAY_API_KEY?: string;
readonly TOOLS_EXA_API_KEY?: string;
+ readonly ONBOARDING_AGENT_BUNDLE_URL: string;
// OAuth provider credentials
readonly GITHUB_CLIENT_ID?: string;
@@ -227,6 +234,8 @@ export interface Bindings {
readonly GOOGLE_CLIENT_ID?: string;
readonly GOOGLE_CLIENT_SECRET?: string;
+ readonly serverVersion: string;
+
// Optional AWS credentials used by platform logging to CloudWatch
readonly AWS_ACCESS_KEY_ID?: string;
readonly AWS_SECRET_ACCESS_KEY?: string;
@@ -311,6 +320,7 @@ mountMessages(api.basePath("/messages"));
mountTools(api.basePath("/tools"));
mountOtlp(api.basePath("/otlp"));
mountDevhook(api.basePath("/devhook"));
+mountOnboarding(api.basePath("/onboarding"));
// Webhook route for proxying requests to agents
// The wildcard route handles subpaths like /api/webhook/:id/github/events
diff --git a/internal/api/src/test.ts b/internal/api/src/test.ts
index 12206fea..2e4e481b 100644
--- a/internal/api/src/test.ts
+++ b/internal/api/src/test.ts
@@ -87,6 +87,7 @@ export const serve = async (options?: ServeOptions) => {
const agentStore = new Map();
const bindings: Bindings = {
apiBaseURL: srv.url,
+ accessUrl: srv.url,
matchRequestHost: (hostname) => {
const regex = new RegExp(`^(.*)\.${srv.url.host}$`);
const exec = regex.exec(hostname);
@@ -112,6 +113,9 @@ export const serve = async (options?: ServeOptions) => {
},
AUTH_SECRET: authSecret,
NODE_ENV: "development",
+ serverVersion: "test",
+ ONBOARDING_AGENT_BUNDLE_URL:
+ options?.bindings?.ONBOARDING_AGENT_BUNDLE_URL ?? "override-me-in-test",
...options?.bindings,
agentStore: (targetID) => {
let store = agentStore.get(targetID);
diff --git a/internal/api/src/util/chat.ts b/internal/api/src/util/chat.ts
index 99af199c..560ecc0e 100644
--- a/internal/api/src/util/chat.ts
+++ b/internal/api/src/util/chat.ts
@@ -147,6 +147,13 @@ export async function runChat({
},
});
+ if (!env.AUTH_SECRET) {
+ // biome-ignore lint/suspicious/noConsole: we want to notify the admin if this happens.
+ console.error(
+ "runChat: AUTH_SECRET environment variable is not set. Unable to generate agent invocation token."
+ );
+ throw new Error("Internal server error");
+ }
const { generateAgentInvocationToken } = await import(
"@blink.so/api/agents/me/server"
);
diff --git a/internal/database/migrations/0001_onboarding.sql b/internal/database/migrations/0001_onboarding.sql
new file mode 100644
index 00000000..601a883d
--- /dev/null
+++ b/internal/database/migrations/0001_onboarding.sql
@@ -0,0 +1,4 @@
+ALTER TABLE "agent" ADD COLUMN "slack_verification" jsonb;--> statement-breakpoint
+ALTER TABLE "agent" ADD COLUMN "github_app_setup" jsonb;--> statement-breakpoint
+ALTER TABLE "agent" ADD COLUMN "onboarding_state" jsonb;--> statement-breakpoint
+ALTER TABLE "agent" ADD COLUMN "integrations_state" jsonb;
\ No newline at end of file
diff --git a/internal/database/migrations/meta/0001_snapshot.json b/internal/database/migrations/meta/0001_snapshot.json
new file mode 100644
index 00000000..7a79ac9f
--- /dev/null
+++ b/internal/database/migrations/meta/0001_snapshot.json
@@ -0,0 +1,3281 @@
+{
+ "id": "83ed9112-fd97-4f5c-9d2e-02082cd5b929",
+ "prevId": "645fa823-648d-48c5-987d-c9d72bb0659e",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.agent": {
+ "name": "agent",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'organization'"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_file_id": {
+ "name": "avatar_file_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "active_deployment_id": {
+ "name": "active_deployment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chat_expire_ttl": {
+ "name": "chat_expire_ttl",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_deployment_number": {
+ "name": "last_deployment_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_run_number": {
+ "name": "last_run_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "slack_verification": {
+ "name": "slack_verification",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "github_app_setup": {
+ "name": "github_app_setup",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "onboarding_state": {
+ "name": "onboarding_state",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "integrations_state": {
+ "name": "integrations_state",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "agent_name_unique": {
+ "name": "agent_name_unique",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_organization_id_organization_id_fk": {
+ "name": "agent_organization_id_organization_id_fk",
+ "tableFrom": "agent",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "name_format": {
+ "name": "name_format",
+ "value": "\"agent\".\"name\" ~* '^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.agent_deployment": {
+ "name": "agent_deployment",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "number": {
+ "name": "number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_from": {
+ "name": "created_from",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "entrypoint": {
+ "name": "entrypoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "compatibility_version": {
+ "name": "compatibility_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'1'"
+ },
+ "source_files": {
+ "name": "source_files",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "output_files": {
+ "name": "output_files",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_message": {
+ "name": "user_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "platform": {
+ "name": "platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "platform_memory_mb": {
+ "name": "platform_memory_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "platform_region": {
+ "name": "platform_region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "platform_metadata": {
+ "name": "platform_metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "direct_access_url": {
+ "name": "direct_access_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "agent_deployment_agent_id_number_unique": {
+ "name": "agent_deployment_agent_id_number_unique",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_deployment_agent_id_agent_id_fk": {
+ "name": "agent_deployment_agent_id_agent_id_fk",
+ "tableFrom": "agent_deployment",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_deployment_target_id_agent_deployment_target_id_fk": {
+ "name": "agent_deployment_target_id_agent_deployment_target_id_fk",
+ "tableFrom": "agent_deployment",
+ "tableTo": "agent_deployment_target",
+ "columnsFrom": ["target_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_deployment_log": {
+ "name": "agent_deployment_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deployment_id": {
+ "name": "deployment_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agent_deployment_log_agent_id_agent_id_fk": {
+ "name": "agent_deployment_log_agent_id_agent_id_fk",
+ "tableFrom": "agent_deployment_log",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_deployment_target": {
+ "name": "agent_deployment_target",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "agent_deployment_target_agent_id_target_unique": {
+ "name": "agent_deployment_target_agent_id_target_unique",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_deployment_target_agent_id_agent_id_fk": {
+ "name": "agent_deployment_target_agent_id_agent_id_fk",
+ "tableFrom": "agent_deployment_target",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "agent_deployment_target_request_id_unique": {
+ "name": "agent_deployment_target_request_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["request_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_environment_variable": {
+ "name": "agent_environment_variable",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "encrypted_dek": {
+ "name": "encrypted_dek",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "encryption_iv": {
+ "name": "encryption_iv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "encryption_auth_tag": {
+ "name": "encryption_auth_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "secret": {
+ "name": "secret",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"preview\",\"production\"}'"
+ }
+ },
+ "indexes": {
+ "agent_environment_variable_agent_id_idx": {
+ "name": "agent_environment_variable_agent_id_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_env_key_prod_unique": {
+ "name": "agent_env_key_prod_unique",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "'production' = ANY(\"agent_environment_variable\".\"target\")",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_env_key_prev_unique": {
+ "name": "agent_env_key_prev_unique",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "'preview' = ANY(\"agent_environment_variable\".\"target\")",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_environment_variable_agent_id_agent_id_fk": {
+ "name": "agent_environment_variable_agent_id_agent_id_fk",
+ "tableFrom": "agent_environment_variable",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_log": {
+ "name": "agent_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "level": {
+ "name": "level",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'info'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "payload_str": {
+ "name": "payload_str",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "agent_log_agent_time_idx": {
+ "name": "agent_log_agent_time_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "timestamp",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_log_agent_id_agent_id_fk": {
+ "name": "agent_log_agent_id_agent_id_fk",
+ "tableFrom": "agent_log",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_permission": {
+ "name": "agent_permission",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "agent_permission_agent_id_user_id_unique": {
+ "name": "agent_permission_agent_id_user_id_unique",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_permission_agent_id_index": {
+ "name": "agent_permission_agent_id_index",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_permission_agent_id_agent_id_fk": {
+ "name": "agent_permission_agent_id_agent_id_fk",
+ "tableFrom": "agent_permission",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_permission_user_id_user_id_fk": {
+ "name": "agent_permission_user_id_user_id_fk",
+ "tableFrom": "agent_permission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_permission_created_by_user_id_fk": {
+ "name": "agent_permission_created_by_user_id_fk",
+ "tableFrom": "agent_permission",
+ "tableTo": "user",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_pin": {
+ "name": "agent_pin",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "agent_pin_agent_id_user_id_unique": {
+ "name": "agent_pin_agent_id_user_id_unique",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_pin_agent_id_agent_id_fk": {
+ "name": "agent_pin_agent_id_agent_id_fk",
+ "tableFrom": "agent_pin",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_pin_user_id_user_id_fk": {
+ "name": "agent_pin_user_id_user_id_fk",
+ "tableFrom": "agent_pin",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_storage_kv": {
+ "name": "agent_storage_kv",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_deployment_target_id": {
+ "name": "agent_deployment_target_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "agent_storage_kv_agent_deployment_target_id_key_unique": {
+ "name": "agent_storage_kv_agent_deployment_target_id_key_unique",
+ "columns": [
+ {
+ "expression": "agent_deployment_target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_storage_kv_agent_id_agent_id_fk": {
+ "name": "agent_storage_kv_agent_id_agent_id_fk",
+ "tableFrom": "agent_storage_kv",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_storage_kv_agent_deployment_target_id_agent_deployment_target_id_fk": {
+ "name": "agent_storage_kv_agent_deployment_target_id_agent_deployment_target_id_fk",
+ "tableFrom": "agent_storage_kv",
+ "tableTo": "agent_deployment_target",
+ "columnsFrom": ["agent_deployment_target_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_trace": {
+ "name": "agent_trace",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_time": {
+ "name": "end_time",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload_original": {
+ "name": "payload_original",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload_str": {
+ "name": "payload_str",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "agent_trace_agent_time_idx": {
+ "name": "agent_trace_agent_time_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "start_time",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_trace_agent_id_agent_id_fk": {
+ "name": "agent_trace_agent_id_agent_id_fk",
+ "tableFrom": "agent_trace",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_key": {
+ "name": "api_key",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_lookup": {
+ "name": "key_lookup",
+ "type": "varchar(12)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_prefix": {
+ "name": "key_prefix",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_suffix": {
+ "name": "key_suffix",
+ "type": "varchar(4)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'full'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_by": {
+ "name": "revoked_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "api_key_user_idx": {
+ "name": "api_key_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "api_key_lookup_idx": {
+ "name": "api_key_lookup_idx",
+ "columns": [
+ {
+ "expression": "key_lookup",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "api_key_user_id_user_id_fk": {
+ "name": "api_key_user_id_user_id_fk",
+ "tableFrom": "api_key",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "api_key_revoked_by_user_id_fk": {
+ "name": "api_key_revoked_by_user_id_fk",
+ "tableFrom": "api_key",
+ "tableTo": "user",
+ "columnsFrom": ["revoked_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_key_key_lookup_unique": {
+ "name": "api_key_key_lookup_unique",
+ "nullsNotDistinct": false,
+ "columns": ["key_lookup"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat": {
+ "name": "chat",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'private'"
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_deployment_id": {
+ "name": "agent_deployment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_deployment_target_id": {
+ "name": "agent_deployment_target_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_key": {
+ "name": "agent_key",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_run_number": {
+ "name": "last_run_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "expire_ttl": {
+ "name": "expire_ttl",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "chat_organization_created_at_idx": {
+ "name": "chat_organization_created_at_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chat_organization_created_by": {
+ "name": "idx_chat_organization_created_by",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chat_visibility": {
+ "name": "idx_chat_visibility",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visibility",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"chat\".\"visibility\" IN ('public', 'private', 'organization')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chat_expire_ttl": {
+ "name": "idx_chat_expire_ttl",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"chat\".\"expire_ttl\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_chat_agent_deployment_target_id_key_unique": {
+ "name": "idx_chat_agent_deployment_target_id_key_unique",
+ "columns": [
+ {
+ "expression": "agent_deployment_target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_organization_id_organization_id_fk": {
+ "name": "chat_organization_id_organization_id_fk",
+ "tableFrom": "chat",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_agent_id_agent_id_fk": {
+ "name": "chat_agent_id_agent_id_fk",
+ "tableFrom": "chat",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "chat_agent_deployment_id_agent_deployment_id_fk": {
+ "name": "chat_agent_deployment_id_agent_deployment_id_fk",
+ "tableFrom": "chat",
+ "tableTo": "agent_deployment",
+ "columnsFrom": ["agent_deployment_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "chat_agent_deployment_target_id_agent_deployment_target_id_fk": {
+ "name": "chat_agent_deployment_target_id_agent_deployment_target_id_fk",
+ "tableFrom": "chat",
+ "tableTo": "agent_deployment_target",
+ "columnsFrom": ["agent_deployment_target_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_run": {
+ "name": "chat_run",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_deployment_id": {
+ "name": "agent_deployment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_step_number": {
+ "name": "last_step_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "chat_run_chat_id_number_unique": {
+ "name": "chat_run_chat_id_number_unique",
+ "columns": [
+ {
+ "expression": "chat_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_run_chat_id_chat_id_fk": {
+ "name": "chat_run_chat_id_chat_id_fk",
+ "tableFrom": "chat_run",
+ "tableTo": "chat",
+ "columnsFrom": ["chat_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_run_agent_id_agent_id_fk": {
+ "name": "chat_run_agent_id_agent_id_fk",
+ "tableFrom": "chat_run",
+ "tableTo": "agent",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_run_step": {
+ "name": "chat_run_step",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chat_run_id": {
+ "name": "chat_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_deployment_id": {
+ "name": "agent_deployment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "heartbeat_at": {
+ "name": "heartbeat_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "interrupted_at": {
+ "name": "interrupted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_message_id": {
+ "name": "first_message_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_id": {
+ "name": "last_message_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_status": {
+ "name": "response_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_headers": {
+ "name": "response_headers",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_headers_redacted": {
+ "name": "response_headers_redacted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "response_body": {
+ "name": "response_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_body_redacted": {
+ "name": "response_body_redacted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "response_message_id": {
+ "name": "response_message_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "continuation_reason": {
+ "name": "continuation_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_to_first_token_micros": {
+ "name": "time_to_first_token_micros",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tool_calls_total": {
+ "name": "tool_calls_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "tool_calls_completed": {
+ "name": "tool_calls_completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "tool_calls_errored": {
+ "name": "tool_calls_errored",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "usage_cost_usd": {
+ "name": "usage_cost_usd",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_model": {
+ "name": "usage_model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_total_input_tokens": {
+ "name": "usage_total_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_total_output_tokens": {
+ "name": "usage_total_output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_total_tokens": {
+ "name": "usage_total_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_total_cached_input_tokens": {
+ "name": "usage_total_cached_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "chat_run_step_chat_run_id_id_unique": {
+ "name": "chat_run_step_chat_run_id_id_unique",
+ "columns": [
+ {
+ "expression": "chat_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "chat_run_step_single_streaming": {
+ "name": "chat_run_step_single_streaming",
+ "columns": [
+ {
+ "expression": "chat_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"chat_run_step\".\"completed_at\" IS NULL AND \"chat_run_step\".\"error\" IS NULL AND \"chat_run_step\".\"interrupted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "chat_run_step_agent_id_started_at_idx": {
+ "name": "chat_run_step_agent_id_started_at_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "chat_run_step_agent_deployment_id_started_at_idx": {
+ "name": "chat_run_step_agent_deployment_id_started_at_idx",
+ "columns": [
+ {
+ "expression": "agent_deployment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_run_step_chat_id_chat_id_fk": {
+ "name": "chat_run_step_chat_id_chat_id_fk",
+ "tableFrom": "chat_run_step",
+ "tableTo": "chat",
+ "columnsFrom": ["chat_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_run_step_chat_run_id_chat_run_id_fk": {
+ "name": "chat_run_step_chat_run_id_chat_run_id_fk",
+ "tableFrom": "chat_run_step",
+ "tableTo": "chat_run",
+ "columnsFrom": ["chat_run_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_user_state": {
+ "name": "chat_user_state",
+ "schema": "",
+ "columns": {
+ "chat_id": {
+ "name": "chat_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_read_at": {
+ "name": "last_read_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "chat_user_state_chat_id_chat_id_fk": {
+ "name": "chat_user_state_chat_id_chat_id_fk",
+ "tableFrom": "chat_user_state",
+ "tableTo": "chat",
+ "columnsFrom": ["chat_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_user_state_user_id_user_id_fk": {
+ "name": "chat_user_state_user_id_user_id_fk",
+ "tableFrom": "chat_user_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "chat_user_state_chat_id_user_id_pk": {
+ "name": "chat_user_state_chat_id_user_id_pk",
+ "columns": ["chat_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.email_verification": {
+ "name": "email_verification",
+ "schema": "",
+ "columns": {
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_email_verification_email_code": {
+ "name": "idx_email_verification_email_code",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.file": {
+ "name": "file",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "byte_length": {
+ "name": "byte_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "pdf_page_count": {
+ "name": "pdf_page_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.message": {
+ "name": "message",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chat_run_id": {
+ "name": "chat_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chat_run_step_id": {
+ "name": "chat_run_step_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parts": {
+ "name": "parts",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_deployment_id": {
+ "name": "agent_deployment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_message_chat_role_created": {
+ "name": "idx_message_chat_role_created",
+ "columns": [
+ {
+ "expression": "chat_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"message\".\"role\" = 'user'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "message_chat_id_chat_id_fk": {
+ "name": "message_chat_id_chat_id_fk",
+ "tableFrom": "message",
+ "tableTo": "chat",
+ "columnsFrom": ["chat_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization": {
+ "name": "organization",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'organization'"
+ },
+ "personal_owner_user_id": {
+ "name": "personal_owner_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "billing_tier": {
+ "name": "billing_tier",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'free'"
+ },
+ "billing_interval": {
+ "name": "billing_interval",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'month'"
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metronome_customer_id": {
+ "name": "metronome_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metronome_contract_id": {
+ "name": "metronome_contract_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripe_subscription_id": {
+ "name": "stripe_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_billing_date": {
+ "name": "next_billing_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "billing_entitled_at": {
+ "name": "billing_entitled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "personal_org_per_user": {
+ "name": "personal_org_per_user",
+ "columns": [
+ {
+ "expression": "personal_owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"organization\".\"kind\" = 'personal'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "organization_personal_owner_user_id_user_id_fk": {
+ "name": "organization_personal_owner_user_id_user_id_fk",
+ "tableFrom": "organization",
+ "tableTo": "user",
+ "columnsFrom": ["personal_owner_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_name_unique": {
+ "name": "organization_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "name_format": {
+ "name": "name_format",
+ "value": "\"organization\".\"name\" ~* '^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$'"
+ },
+ "name_not_reserved": {
+ "name": "name_not_reserved",
+ "value": "\"organization\".\"name\" NOT IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)"
+ },
+ "personal_owner_presence": {
+ "name": "personal_owner_presence",
+ "value": "(\"organization\".\"kind\" = 'personal' AND \"organization\".\"personal_owner_user_id\" IS NOT NULL)\n OR (\"organization\".\"kind\" = 'organization' AND \"organization\".\"personal_owner_user_id\" IS NULL)"
+ },
+ "personal_created_by_matches_owner": {
+ "name": "personal_created_by_matches_owner",
+ "value": "\"organization\".\"kind\" != 'personal' OR \"organization\".\"created_by\" = \"organization\".\"personal_owner_user_id\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.organization_billing_usage_event": {
+ "name": "organization_billing_usage_event",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transaction_id": {
+ "name": "transaction_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cost_usd": {
+ "name": "cost_usd",
+ "type": "numeric(32, 18)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "organization_billing_usage_event_org_txn_unique": {
+ "name": "organization_billing_usage_event_org_txn_unique",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "transaction_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization_invite": {
+ "name": "organization_invite",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "invited_by": {
+ "name": "invited_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reusable": {
+ "name": "reusable",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "last_accepted_at": {
+ "name": "last_accepted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "organization_invite_organization_id_organization_id_fk": {
+ "name": "organization_invite_organization_id_organization_id_fk",
+ "tableFrom": "organization_invite",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "organization_invite_invited_by_membership_fk": {
+ "name": "organization_invite_invited_by_membership_fk",
+ "tableFrom": "organization_invite",
+ "tableTo": "organization_membership",
+ "columnsFrom": ["organization_id", "invited_by"],
+ "columnsTo": ["organization_id", "user_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_invite_code_unique": {
+ "name": "organization_invite_code_unique",
+ "nullsNotDistinct": false,
+ "columns": ["code"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization_membership": {
+ "name": "organization_membership",
+ "schema": "",
+ "columns": {
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "billing_emails_opt_out": {
+ "name": "billing_emails_opt_out",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "organization_membership_organization_id_organization_id_fk": {
+ "name": "organization_membership_organization_id_organization_id_fk",
+ "tableFrom": "organization_membership",
+ "tableTo": "organization",
+ "columnsFrom": ["organization_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "organization_membership_user_id_user_id_fk": {
+ "name": "organization_membership_user_id_user_id_fk",
+ "tableFrom": "organization_membership",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "organization_membership_organization_id_user_id_pk": {
+ "name": "organization_membership_organization_id_user_id_pk",
+ "columns": ["organization_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_account": {
+ "name": "user_account",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_account_user_id_user_id_fk": {
+ "name": "user_account_user_id_user_id_fk",
+ "tableFrom": "user_account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_account_provider_provider_account_id_pk": {
+ "name": "user_account_provider_provider_account_id_pk",
+ "columns": ["provider", "provider_account_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {
+ "public.chat_run_step_with_status": {
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chat_run_id": {
+ "name": "chat_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_deployment_id": {
+ "name": "agent_deployment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "heartbeat_at": {
+ "name": "heartbeat_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "interrupted_at": {
+ "name": "interrupted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_message_id": {
+ "name": "first_message_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_id": {
+ "name": "last_message_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_status": {
+ "name": "response_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_headers": {
+ "name": "response_headers",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_headers_redacted": {
+ "name": "response_headers_redacted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "response_body": {
+ "name": "response_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_body_redacted": {
+ "name": "response_body_redacted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "response_message_id": {
+ "name": "response_message_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "continuation_reason": {
+ "name": "continuation_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_to_first_token_micros": {
+ "name": "time_to_first_token_micros",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tool_calls_total": {
+ "name": "tool_calls_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "tool_calls_completed": {
+ "name": "tool_calls_completed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "tool_calls_errored": {
+ "name": "tool_calls_errored",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "usage_cost_usd": {
+ "name": "usage_cost_usd",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_model": {
+ "name": "usage_model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_total_input_tokens": {
+ "name": "usage_total_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_total_output_tokens": {
+ "name": "usage_total_output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_total_tokens": {
+ "name": "usage_total_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_total_cached_input_tokens": {
+ "name": "usage_total_cached_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "definition": "select \"id\", \"number\", \"chat_id\", \"chat_run_id\", \"agent_id\", \"agent_deployment_id\", \"started_at\", \"heartbeat_at\", \"completed_at\", \"interrupted_at\", \"first_message_id\", \"last_message_id\", \"error\", \"response_status\", \"response_headers\", \"response_headers_redacted\", \"response_body\", \"response_body_redacted\", \"response_message_id\", \"continuation_reason\", \"time_to_first_token_micros\", \"tool_calls_total\", \"tool_calls_completed\", \"tool_calls_errored\", \"usage_cost_usd\", \"usage_model\", \"usage_total_input_tokens\", \"usage_total_output_tokens\", \"usage_total_tokens\", \"usage_total_cached_input_tokens\", CASE\n WHEN \"error\" IS NOT NULL THEN 'error'\n WHEN \"interrupted_at\" IS NOT NULL THEN 'interrupted'\n WHEN \"completed_at\" IS NOT NULL THEN 'completed'\n WHEN \"continuation_reason\" IS NOT NULL THEN 'streaming'\n WHEN \"heartbeat_at\" < NOW() - INTERVAL '90 seconds' THEN 'stalled'\n ELSE 'streaming'\nEND as \"status\" from \"chat_run_step\"",
+ "name": "chat_run_step_with_status",
+ "schema": "public",
+ "isExisting": false,
+ "materialized": false
+ },
+ "public.chat_run_with_status": {
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_step_number": {
+ "name": "last_step_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "definition": "select \"chat_run\".\"id\", \"chat_run\".\"number\", \"chat_run\".\"chat_id\", COALESCE(\"chat_run_step_with_status\".\"agent_id\", \"chat_run\".\"agent_id\") as \"agent_id\", COALESCE(\"chat_run_step_with_status\".\"agent_deployment_id\", \"chat_run\".\"agent_deployment_id\") as \"agent_deployment_id\", \"chat_run\".\"created_at\", \"chat_run\".\"last_step_number\", COALESCE(\"chat_run_step_with_status\".\"completed_at\", \"chat_run_step_with_status\".\"interrupted_at\", \"chat_run_step_with_status\".\"heartbeat_at\", \"chat_run_step_with_status\".\"started_at\", \"chat_run\".\"created_at\") as \"updated_at\", \"chat_run_step_with_status\".\"error\", \"status\" from \"chat_run\" left join \"chat_run_step_with_status\" on (\"chat_run\".\"id\" = \"chat_run_step_with_status\".\"chat_run_id\" and \"chat_run_step_with_status\".\"number\" = \"chat_run\".\"last_step_number\")",
+ "name": "chat_run_with_status",
+ "schema": "public",
+ "isExisting": false,
+ "materialized": false
+ },
+ "public.chat_with_status": {
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'private'"
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_deployment_target_id": {
+ "name": "agent_deployment_target_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_key": {
+ "name": "agent_key",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_run_number": {
+ "name": "last_run_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "expire_ttl": {
+ "name": "expire_ttl",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "definition": "select \"chat\".\"id\", \"chat\".\"created_at\", \"chat\".\"created_by\", \"chat\".\"organization_id\", \"chat\".\"visibility\", \"chat\".\"title\", \"chat\".\"metadata\", \"chat\".\"archived\", \"chat\".\"agent_id\", COALESCE(\"agent_deployment_id\", \"chat\".\"agent_deployment_id\") as \"agent_deployment_id\", \"chat\".\"agent_deployment_target_id\", \"chat\".\"agent_key\", \"chat\".\"last_run_number\", \"chat\".\"expire_ttl\", COALESCE(\"updated_at\", \"chat\".\"created_at\") as \"updated_at\", \"chat_run_with_status\".\"error\", CASE\n WHEN \"status\" IS NULL THEN 'idle'\n WHEN \"status\" IN ('error', 'stalled') THEN 'error'\n WHEN \"status\" = 'interrupted' THEN 'interrupted'\n WHEN \"status\" IN ('completed', 'idle') THEN 'idle'\n ELSE 'streaming'\n END as \"status\", CASE \n WHEN \"chat\".\"expire_ttl\" IS NULL THEN NULL\n ELSE COALESCE(\"updated_at\", \"chat\".\"created_at\") + (\"chat\".\"expire_ttl\" || ' seconds')::interval\n END as \"expires_at\" from \"chat\" left join \"chat_run_with_status\" on (\"chat\".\"id\" = \"chat_run_with_status\".\"chat_id\" and \"chat_run_with_status\".\"number\" = \"chat\".\"last_run_number\")",
+ "name": "chat_with_status",
+ "schema": "public",
+ "isExisting": false,
+ "materialized": false
+ },
+ "public.user_with_personal_organization": {
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "definition": "select \"user\".\"id\", \"user\".\"created_at\", \"user\".\"updated_at\", \"user\".\"display_name\", \"user\".\"email\", \"user\".\"email_verified\", \"user\".\"password\", \"organization\".\"id\" as \"organization_id\", \"organization\".\"name\" as \"username\", \"organization\".\"avatar_url\" as \"avatar_url\" from \"user\" inner join \"organization\" on \"user\".\"id\" = \"organization\".\"personal_owner_user_id\"",
+ "name": "user_with_personal_organization",
+ "schema": "public",
+ "isExisting": false,
+ "materialized": false
+ }
+ },
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/internal/database/migrations/meta/_journal.json b/internal/database/migrations/meta/_journal.json
index 6e98bbf4..196e3569 100644
--- a/internal/database/migrations/meta/_journal.json
+++ b/internal/database/migrations/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1763742838403,
"tag": "0000_initial",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1767894988953,
+ "tag": "0001_onboarding",
+ "breakpoints": true
}
]
}
diff --git a/internal/database/src/convert.ts b/internal/database/src/convert.ts
index c0005465..dae4ff58 100644
--- a/internal/database/src/convert.ts
+++ b/internal/database/src/convert.ts
@@ -71,6 +71,14 @@ export const agent = (
pinned: "pinned" in agent ? agent.pinned : false,
chat_expire_ttl: agent.chat_expire_ttl,
user_permission: userPermission,
+ onboarding_state:
+ userPermission === "admin" || userPermission === "write"
+ ? (agent.onboarding_state ?? null)
+ : null,
+ integrations_state:
+ userPermission === "admin" || userPermission === "write"
+ ? (agent.integrations_state ?? null)
+ : null,
};
};
diff --git a/internal/database/src/schema.ts b/internal/database/src/schema.ts
index d57edefe..8faf9aaf 100644
--- a/internal/database/src/schema.ts
+++ b/internal/database/src/schema.ts
@@ -488,6 +488,100 @@ export const agent = pgTable(
.notNull()
.default(0),
last_run_number: integer("last_run_number").notNull().default(0),
+
+ // Slack setup verification state (null when no verification in progress)
+ slack_verification: jsonb("slack_verification").$type<{
+ signingSecret: string;
+ botToken: string;
+ startedAt: string;
+ expiresAt: string;
+ lastEventAt?: string;
+ dmReceivedAt?: string;
+ dmChannel?: string;
+ signatureFailedAt?: string;
+ }>(),
+
+ // GitHub App setup state (null when no setup in progress)
+ // Status flow: pending -> app_created -> completed
+ // - pending: waiting for user to create app on GitHub
+ // - app_created: app created, waiting for user to install it
+ // - completed: app created and installed
+ // - failed: error occurred
+ github_app_setup: jsonb("github_app_setup").$type<{
+ sessionId: string;
+ startedAt: string;
+ expiresAt: string;
+ status: "pending" | "app_created" | "completed" | "failed";
+ error?: string;
+ installationId?: string;
+ appData?: {
+ id: number;
+ clientId: string;
+ clientSecret: string;
+ webhookSecret: string;
+ pem: string;
+ name: string;
+ htmlUrl: string;
+ slug: string;
+ };
+ }>(),
+
+ // Onboarding wizard state
+ onboarding_state: jsonb("onboarding_state").$type<{
+ currentStep:
+ | "welcome"
+ | "llm-api-keys"
+ | "github-setup"
+ | "slack-setup"
+ | "web-search"
+ | "deploying"
+ | "success";
+ finished?: boolean;
+ github?: {
+ appName: string;
+ appUrl: string;
+ installUrl: string;
+ appId?: number;
+ clientId?: string;
+ clientSecret?: string;
+ webhookSecret?: string;
+ privateKey?: string;
+ envVars?: {
+ appId: string;
+ clientId: string;
+ clientSecret: string;
+ webhookSecret: string;
+ privateKey: string;
+ };
+ };
+ slack?: {
+ botToken: string;
+ signingSecret: string;
+ envVars?: {
+ botToken: string;
+ signingSecret: string;
+ };
+ };
+ llm?: {
+ provider?: "anthropic" | "openai" | "vercel";
+ apiKey?: string;
+ envVar?: string;
+ };
+ webSearch?: {
+ provider?: "exa";
+ apiKey?: string;
+ envVar?: string;
+ };
+ }>(),
+
+ // Integrations configuration state - tracks which integrations are configured
+ // This is separate from onboarding_state to persist after onboarding completes
+ integrations_state: jsonb("integrations_state").$type<{
+ llm?: boolean;
+ github?: boolean;
+ slack?: boolean;
+ webSearch?: boolean;
+ }>(),
},
(table) => [
check(
diff --git a/internal/site/.storybook/main.ts b/internal/site/.storybook/main.ts
index b9f97bf2..2d14d7ba 100644
--- a/internal/site/.storybook/main.ts
+++ b/internal/site/.storybook/main.ts
@@ -4,6 +4,7 @@ import path from "path";
import { fileURLToPath } from "url";
import Inspect from "vite-plugin-inspect";
import { nodePolyfills } from "vite-plugin-node-polyfills";
+import { mergeConfig } from "vite";
const config: StorybookConfig = {
stories: ["../**/*.stories.@(js|jsx|mjs|ts|tsx)"],
@@ -87,7 +88,9 @@ const config: StorybookConfig = {
config.plugins.push(Inspect());
config.plugins.push(nodePolyfills());
- return config;
+ return mergeConfig(config, {
+ server: { watch: { usePolling: true, interval: 1000 } },
+ });
},
};
export default config;
diff --git a/internal/site/app/(app)/[organization]/[agent]/layout.tsx b/internal/site/app/(app)/[organization]/[agent]/layout.tsx
index 2cdad99e..89d7ac20 100644
--- a/internal/site/app/(app)/[organization]/[agent]/layout.tsx
+++ b/internal/site/app/(app)/[organization]/[agent]/layout.tsx
@@ -21,6 +21,12 @@ export default async function AgentLayout({
getOrganization(session.user.id, organizationName),
getAgent(organizationName, agentName),
]);
+
+ // Redirect to onboarding if agent is being onboarded (finished === false)
+ if (agent.onboarding_state?.finished === false) {
+ return redirect(`/${organizationName}/~/onboarding/${agentName}`);
+ }
+
const user = await getUser(session.user.id);
// Get organization kind from database for navigation
diff --git a/internal/site/app/(app)/[organization]/[agent]/page.stories.tsx b/internal/site/app/(app)/[organization]/[agent]/page.stories.tsx
index 44c995c1..89adabb0 100644
--- a/internal/site/app/(app)/[organization]/[agent]/page.stories.tsx
+++ b/internal/site/app/(app)/[organization]/[agent]/page.stories.tsx
@@ -1,13 +1,20 @@
-import { withFetch } from "@/.storybook/utils";
-import Layout from "@/app/(app)/layout";
-import { getQuerier } from "@/lib/database.mock";
import type { Meta, StoryObj } from "@storybook/react";
-import { mocked } from "storybook/test";
import { SessionProvider } from "next-auth/react";
+import { mocked } from "storybook/test";
+import Layout from "@/app/(app)/layout";
+import { type MockedClient, withMockClient } from "@/lib/api-client.mock";
+import { getQuerier } from "@/lib/database.mock";
import OrganizationLayout from "../layout";
import AgentLayout from "./layout";
import AgentPage from "./page";
+function configureMockClient(client: MockedClient) {
+ client.agents.steps.list.mockResolvedValue({
+ items: [],
+ next_cursor: null,
+ });
+}
+
const meta: Meta = {
title: "Page/Agent",
component: AgentPage,
@@ -64,24 +71,12 @@ export const Default: Story = {
chat_expire_ttl: null,
last_deployment_number: 0,
last_run_number: 0,
+ slack_verification: null,
+ github_app_setup: null,
+ onboarding_state: null,
+ integrations_state: null,
}),
});
},
- decorators: [
- withFetch((url) => {
- if (!url.pathname.endsWith("/steps")) {
- return undefined;
- }
-
- return new Response(
- JSON.stringify({
- items: [],
- has_more: false,
- }),
- {
- status: 200,
- }
- );
- }),
- ],
+ decorators: [withMockClient(configureMockClient)],
};
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.stories.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.stories.tsx
new file mode 100644
index 00000000..d22ec035
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.stories.tsx
@@ -0,0 +1,264 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Key, Search } from "lucide-react";
+import { useState } from "react";
+import { fn } from "storybook/test";
+import { GitHubIcon } from "@/components/icons/github";
+import { SlackIcon } from "@/components/slack-icon";
+import { type EnvVarConfig, EnvVarConfirmation } from "./env-var-confirmation";
+
+// Wrapper component that manages state
+function StatefulEnvVarConfirmation({
+ initialEnvVars,
+ ...props
+}: Omit<
+ React.ComponentProps,
+ "envVars" | "onEnvVarsChange"
+> & {
+ initialEnvVars: EnvVarConfig[];
+}) {
+ const [envVars, setEnvVars] = useState(initialEnvVars);
+ return (
+
+ );
+}
+
+const meta: Meta = {
+ title: "Settings/Integrations/EnvVarConfirmation",
+ component: StatefulEnvVarConfirmation,
+ parameters: {
+ layout: "centered",
+ },
+ args: {
+ onSave: fn(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }),
+ onCancel: fn(),
+ onBack: fn(),
+ saving: false,
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const LLM_SingleKey: Story = {
+ args: {
+ title: "LLM API Key",
+ description: "Save your LLM API key as an environment variable",
+ icon: ,
+ iconBgColor: "bg-amber-500",
+ initialEnvVars: [
+ {
+ defaultKey: "ANTHROPIC_API_KEY",
+ currentKey: "ANTHROPIC_API_KEY",
+ value: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz123456789",
+ secret: true,
+ },
+ ],
+ },
+};
+LLM_SingleKey.storyName = "LLM - Single API Key";
+
+export const WebSearch_SingleKey: Story = {
+ args: {
+ title: "Web Search (Exa)",
+ description: "Save your Exa API key as an environment variable",
+ icon: ,
+ iconBgColor: "bg-blue-500",
+ initialEnvVars: [
+ {
+ defaultKey: "EXA_API_KEY",
+ currentKey: "EXA_API_KEY",
+ value: "exa-12345678-abcd-efgh-ijkl-mnopqrstuvwx",
+ secret: true,
+ },
+ ],
+ },
+};
+WebSearch_SingleKey.storyName = "Web Search - Single API Key";
+
+export const GitHub_MultipleKeys: Story = {
+ args: {
+ title: "GitHub App",
+ description: "Save your GitHub App credentials as environment variables",
+ icon: ,
+ iconBgColor: "bg-[#24292f]",
+ initialEnvVars: [
+ {
+ defaultKey: "GITHUB_APP_ID",
+ currentKey: "GITHUB_APP_ID",
+ value: "123456",
+ secret: false,
+ },
+ {
+ defaultKey: "GITHUB_CLIENT_ID",
+ currentKey: "GITHUB_CLIENT_ID",
+ value: "Iv1.abc123def456ghi7",
+ secret: false,
+ },
+ {
+ defaultKey: "GITHUB_CLIENT_SECRET",
+ currentKey: "GITHUB_CLIENT_SECRET",
+ value: "abcdef1234567890abcdef1234567890abcdef12",
+ secret: true,
+ },
+ {
+ defaultKey: "GITHUB_WEBHOOK_SECRET",
+ currentKey: "GITHUB_WEBHOOK_SECRET",
+ value: "webhook-secret-12345",
+ secret: true,
+ },
+ {
+ defaultKey: "GITHUB_PRIVATE_KEY",
+ currentKey: "GITHUB_PRIVATE_KEY",
+ value:
+ "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBdGVzdC...",
+ secret: true,
+ },
+ ],
+ },
+};
+GitHub_MultipleKeys.storyName = "GitHub - Multiple Credentials";
+
+export const Slack_TwoKeys: Story = {
+ args: {
+ title: "Slack",
+ description: "Save your Slack credentials as environment variables",
+ icon: ,
+ iconBgColor: "bg-[#4A154B]",
+ initialEnvVars: [
+ {
+ defaultKey: "SLACK_BOT_TOKEN",
+ currentKey: "SLACK_BOT_TOKEN",
+ value: "xoxb-123456789012-123456789012-abcdefghijklmnopqrstuvwx",
+ secret: true,
+ },
+ {
+ defaultKey: "SLACK_SIGNING_SECRET",
+ currentKey: "SLACK_SIGNING_SECRET",
+ value: "abcdef1234567890abcdef1234567890",
+ secret: true,
+ },
+ ],
+ },
+};
+Slack_TwoKeys.storyName = "Slack - Two Credentials";
+
+export const Saving: Story = {
+ args: {
+ title: "LLM API Key",
+ description: "Save your LLM API key as an environment variable",
+ icon: ,
+ iconBgColor: "bg-amber-500",
+ saving: true,
+ initialEnvVars: [
+ {
+ defaultKey: "ANTHROPIC_API_KEY",
+ currentKey: "ANTHROPIC_API_KEY",
+ value: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz123456789",
+ secret: true,
+ },
+ ],
+ },
+};
+Saving.storyName = "Saving State";
+
+export const WithoutBackButton: Story = {
+ args: {
+ title: "LLM API Key",
+ description: "Save your LLM API key as an environment variable",
+ icon: ,
+ iconBgColor: "bg-amber-500",
+ onBack: undefined,
+ initialEnvVars: [
+ {
+ defaultKey: "ANTHROPIC_API_KEY",
+ currentKey: "ANTHROPIC_API_KEY",
+ value: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz123456789",
+ secret: true,
+ },
+ ],
+ },
+};
+WithoutBackButton.storyName = "Without Back Button";
+
+export const EmptyKeyName: Story = {
+ args: {
+ title: "LLM API Key",
+ description: "Save your LLM API key as an environment variable",
+ icon: ,
+ iconBgColor: "bg-amber-500",
+ initialEnvVars: [
+ {
+ defaultKey: "ANTHROPIC_API_KEY",
+ currentKey: "",
+ value: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz123456789",
+ secret: true,
+ },
+ ],
+ },
+};
+EmptyKeyName.storyName = "Validation - Empty Key Name";
+
+export const DuplicateKeyNames: Story = {
+ args: {
+ title: "Slack",
+ description: "Save your Slack credentials as environment variables",
+ icon: ,
+ iconBgColor: "bg-[#4A154B]",
+ initialEnvVars: [
+ {
+ defaultKey: "SLACK_BOT_TOKEN",
+ currentKey: "SLACK_TOKEN",
+ value: "xoxb-123456789012-123456789012-abcdefghijklmnopqrstuvwx",
+ secret: true,
+ },
+ {
+ defaultKey: "SLACK_SIGNING_SECRET",
+ currentKey: "SLACK_TOKEN",
+ value: "abcdef1234567890abcdef1234567890",
+ secret: true,
+ },
+ ],
+ },
+};
+DuplicateKeyNames.storyName = "Validation - Duplicate Key Names";
+
+export const EditedKeyNames: Story = {
+ args: {
+ title: "GitHub App",
+ description: "Save your GitHub App credentials as environment variables",
+ icon: ,
+ iconBgColor: "bg-[#24292f]",
+ initialEnvVars: [
+ {
+ defaultKey: "GITHUB_APP_ID",
+ currentKey: "MY_GH_APP_ID",
+ value: "123456",
+ secret: false,
+ },
+ {
+ defaultKey: "GITHUB_CLIENT_ID",
+ currentKey: "MY_GH_CLIENT_ID",
+ value: "Iv1.abc123def456ghi7",
+ secret: false,
+ },
+ {
+ defaultKey: "GITHUB_CLIENT_SECRET",
+ currentKey: "MY_GH_CLIENT_SECRET",
+ value: "abcdef1234567890abcdef1234567890abcdef12",
+ secret: true,
+ },
+ ],
+ },
+};
+EditedKeyNames.storyName = "Edited Key Names";
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.tsx
new file mode 100644
index 00000000..0711eb9b
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+import { ArrowLeft, Eye, EyeOff, Info, Loader2 } from "lucide-react";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+
+export interface EnvVarConfig {
+ defaultKey: string; // Original suggested name (e.g., "SLACK_BOT_TOKEN")
+ currentKey: string; // User-editable name
+ value: string; // Actual value (masked in UI for secrets)
+ secret: boolean;
+}
+
+export interface EnvVarConfirmationProps {
+ title: string;
+ description: string;
+ icon: React.ReactNode;
+ iconBgColor: string;
+ envVars: EnvVarConfig[];
+ onEnvVarsChange: (envVars: EnvVarConfig[]) => void;
+ onSave: () => Promise;
+ onCancel: () => void;
+ onBack?: () => void;
+ saving: boolean;
+}
+
+export function EnvVarConfirmation({
+ title,
+ description,
+ icon,
+ iconBgColor,
+ envVars,
+ onEnvVarsChange,
+ onSave,
+ onCancel,
+ onBack,
+ saving,
+}: EnvVarConfirmationProps) {
+ const [revealedIndices, setRevealedIndices] = useState>(
+ new Set()
+ );
+
+ const handleKeyChange = (index: number, newKey: string) => {
+ const updated = [...envVars];
+ updated[index] = { ...updated[index], currentKey: newKey };
+ onEnvVarsChange(updated);
+ };
+
+ const toggleReveal = (index: number) => {
+ setRevealedIndices((prev) => {
+ const next = new Set(prev);
+ if (next.has(index)) {
+ next.delete(index);
+ } else {
+ next.add(index);
+ }
+ return next;
+ });
+ };
+
+ const maskValue = (value: string) => {
+ if (value.length <= 8) {
+ return "••••••••";
+ }
+ return `${value.slice(0, 4)}••••${value.slice(-4)}`;
+ };
+
+ const hasEmptyKeys = envVars.some((env) => !env.currentKey.trim());
+ const hasDuplicateKeys =
+ new Set(envVars.map((env) => env.currentKey)).size !== envVars.length;
+
+ return (
+
+
+
+ {onBack && (
+
+
+
+ )}
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+
+
+
+
+ Review the environment variables that will be saved. You can edit
+ the variable names if needed.
+
+
+
+
+ {envVars.map((envVar, index) => (
+
+
handleKeyChange(index, e.target.value)}
+ placeholder={envVar.defaultKey}
+ disabled={saving}
+ className="shrink-0 font-mono text-sm"
+ style={{
+ width: `calc(${Math.max(envVar.currentKey.length, 12)}ch + 1.75rem)`,
+ }}
+ />
+
+
+ {envVar.secret
+ ? revealedIndices.has(index)
+ ? envVar.value
+ : maskValue(envVar.value)
+ : envVar.value}
+
+ {envVar.secret && (
+ toggleReveal(index)}
+ disabled={saving}
+ >
+ {revealedIndices.has(index) ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ ))}
+
+
+ {hasEmptyKeys && (
+
+ All variable names must be filled in.
+
+ )}
+ {hasDuplicateKeys && (
+
+ Variable names must be unique.
+
+ )}
+
+
+
+ Cancel
+
+
+ {saving && }
+ Save Environment Variables
+
+
+
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/github-integration.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/github-integration.tsx
new file mode 100644
index 00000000..5566253f
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/github-integration.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import {
+ type GitHubAppCredentials,
+ GitHubSetupWizard,
+} from "@/components/github-setup-wizard";
+import { GitHubIcon } from "@/components/icons/github";
+import { EnvVarConfirmation } from "./env-var-confirmation";
+import { useIntegrationSetup } from "./use-integration-setup";
+
+interface GitHubSetupResult {
+ appName: string;
+ appUrl: string;
+ installUrl: string;
+ credentials: GitHubAppCredentials;
+}
+
+interface GitHubIntegrationProps {
+ agentId: string;
+ agentName: string;
+ onComplete: () => void;
+ onCancel: () => void;
+}
+
+export function GitHubIntegration({
+ agentId,
+ agentName,
+ onComplete,
+ onCancel,
+}: GitHubIntegrationProps) {
+ const {
+ step,
+ setStep,
+ envVars,
+ setEnvVars,
+ saving,
+ handleSave,
+ handleSetupComplete,
+ } = useIntegrationSetup({
+ agentId,
+ onComplete,
+ integrationKey: "github",
+ successMessage: "GitHub integration configured successfully",
+ });
+
+ const onSetupComplete = (result: {
+ appName: string;
+ appUrl: string;
+ installUrl: string;
+ credentials: GitHubAppCredentials;
+ }) => {
+ handleSetupComplete(result, [
+ {
+ defaultKey: "GITHUB_APP_ID",
+ currentKey: "GITHUB_APP_ID",
+ value: String(result.credentials.appId),
+ secret: false,
+ },
+ {
+ defaultKey: "GITHUB_CLIENT_ID",
+ currentKey: "GITHUB_CLIENT_ID",
+ value: result.credentials.clientId,
+ secret: false,
+ },
+ {
+ defaultKey: "GITHUB_CLIENT_SECRET",
+ currentKey: "GITHUB_CLIENT_SECRET",
+ value: result.credentials.clientSecret,
+ secret: true,
+ },
+ {
+ defaultKey: "GITHUB_WEBHOOK_SECRET",
+ currentKey: "GITHUB_WEBHOOK_SECRET",
+ value: result.credentials.webhookSecret,
+ secret: true,
+ },
+ {
+ defaultKey: "GITHUB_PRIVATE_KEY",
+ currentKey: "GITHUB_PRIVATE_KEY",
+ value: result.credentials.privateKey,
+ secret: true,
+ },
+ ]);
+ };
+
+ if (step === "confirm") {
+ return (
+ }
+ iconBgColor="bg-[#24292f]"
+ envVars={envVars}
+ onEnvVarsChange={setEnvVars}
+ onSave={handleSave}
+ onCancel={onCancel}
+ onBack={() => setStep("setup")}
+ saving={saving}
+ />
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.stories.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.stories.tsx
new file mode 100644
index 00000000..e414ab62
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.stories.tsx
@@ -0,0 +1,113 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Key, Search } from "lucide-react";
+import { fn } from "storybook/test";
+import { GitHubIcon } from "@/components/icons/github";
+import { SlackIcon } from "@/components/slack-icon";
+import { IntegrationCard } from "./integration-card";
+
+const meta: Meta = {
+ title: "Settings/Integrations/IntegrationCard",
+ component: IntegrationCard,
+ parameters: {
+ layout: "centered",
+ },
+ args: {
+ onConfigure: fn(),
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const LLM_NotConfigured: Story = {
+ args: {
+ title: "LLM API Key",
+ description: "Configure an API key for AI capabilities",
+ icon: ,
+ iconBgColor: "bg-amber-500",
+ configured: false,
+ },
+};
+LLM_NotConfigured.storyName = "LLM - Not Configured";
+
+export const WebSearch_NotConfigured: Story = {
+ args: {
+ title: "Web Search (Exa)",
+ description: "Enable web search capabilities",
+ icon: ,
+ iconBgColor: "bg-blue-500",
+ configured: false,
+ },
+};
+WebSearch_NotConfigured.storyName = "Web Search - Not Configured";
+
+export const GitHub_NotConfigured: Story = {
+ args: {
+ title: "GitHub",
+ description: "Connect to GitHub repositories",
+ icon: ,
+ iconBgColor: "bg-[#24292f]",
+ configured: false,
+ },
+};
+GitHub_NotConfigured.storyName = "GitHub - Not Configured";
+
+export const Slack_NotConfigured: Story = {
+ args: {
+ title: "Slack",
+ description: "Chat with your agent in Slack",
+ icon: ,
+ iconBgColor: "bg-[#4A154B]",
+ configured: false,
+ },
+};
+Slack_NotConfigured.storyName = "Slack - Not Configured";
+
+export const LLM_Configured: Story = {
+ args: {
+ title: "LLM API Key",
+ description: "Configure an API key for AI capabilities",
+ icon: ,
+ iconBgColor: "bg-amber-500",
+ configured: true,
+ },
+};
+LLM_Configured.storyName = "LLM - Configured";
+
+export const WebSearch_Configured: Story = {
+ args: {
+ title: "Web Search (Exa)",
+ description: "Enable web search capabilities",
+ icon: ,
+ iconBgColor: "bg-blue-500",
+ configured: true,
+ },
+};
+WebSearch_Configured.storyName = "Web Search - Configured";
+
+export const GitHub_Configured: Story = {
+ args: {
+ title: "GitHub",
+ description: "Connect to GitHub repositories",
+ icon: ,
+ iconBgColor: "bg-[#24292f]",
+ configured: true,
+ },
+};
+GitHub_Configured.storyName = "GitHub - Configured";
+
+export const Slack_Configured: Story = {
+ args: {
+ title: "Slack",
+ description: "Chat with your agent in Slack",
+ icon: ,
+ iconBgColor: "bg-[#4A154B]",
+ configured: true,
+ },
+};
+Slack_Configured.storyName = "Slack - Configured";
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.tsx
new file mode 100644
index 00000000..c08f85fc
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { Check, Plus, Settings } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+interface IntegrationCardProps {
+ title: string;
+ description: string;
+ icon: React.ReactNode;
+ iconBgColor: string;
+ configured: boolean;
+ onConfigure: () => void;
+}
+
+export function IntegrationCard({
+ title,
+ description,
+ icon,
+ iconBgColor,
+ configured,
+ onConfigure,
+}: IntegrationCardProps) {
+ return (
+
+
+
+
+ {icon}
+
+
+ {title}
+
+ {description}
+
+
+
+ {configured ? (
+ <>
+
+
+ Connected
+
+
+
+
+ >
+ ) : (
+
+
+ Configure
+
+ )}
+
+
+
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.stories.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.stories.tsx
new file mode 100644
index 00000000..304fae07
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.stories.tsx
@@ -0,0 +1,390 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { useState } from "react";
+import { type MockedClient, withMockClient } from "@/lib/api-client.mock";
+import IntegrationsManager from "./integrations-manager";
+
+const TEST_AGENT_ID = "test-agent-123";
+const TEST_WEBHOOK_URL = "https://api.blink.so/webhooks/test-webhook-id";
+const TEST_GITHUB_URL = "https://github.com/settings/apps/new";
+const TEST_MANIFEST = JSON.stringify({
+ name: "Test App",
+ url: "https://blink.so",
+});
+const TEST_SESSION_ID = "test-session-456";
+
+// Configure mock client with default responses for all integrations
+interface MockOptions {
+ // Slack options
+ slackValidationValid?: boolean;
+ slackDmReceived?: boolean;
+ slackSignatureFailed?: boolean;
+ // GitHub options
+ githubCreationStatus?:
+ | "pending"
+ | "app_created"
+ | "completed"
+ | "failed"
+ | "expired";
+ githubAppData?: {
+ id: number;
+ name: string;
+ html_url: string;
+ slug: string;
+ };
+}
+
+// Configure mock client with default responses for all integrations
+function configureMockClient(client: MockedClient, options?: MockOptions) {
+ const {
+ slackValidationValid = true,
+ slackDmReceived = false,
+ slackSignatureFailed = false,
+ githubCreationStatus = "pending",
+ githubAppData = {
+ id: 12345,
+ name: "Test GitHub App",
+ html_url: "https://github.com/apps/test-github-app",
+ slug: "test-github-app",
+ },
+ } = options ?? {};
+
+ // Slack mocks
+ client.agents.setupSlack.getWebhookUrl.mockResolvedValue({
+ webhook_url: TEST_WEBHOOK_URL,
+ });
+ client.agents.setupSlack.startVerification.mockResolvedValue({
+ webhook_url: TEST_WEBHOOK_URL,
+ });
+ client.agents.setupSlack.getVerificationStatus.mockResolvedValue({
+ active: true,
+ started_at: new Date().toISOString(),
+ last_event_at: slackDmReceived ? new Date().toISOString() : undefined,
+ dm_received: slackDmReceived,
+ dm_channel: slackDmReceived ? "D12345678" : undefined,
+ signature_failed: slackSignatureFailed,
+ });
+ client.agents.setupSlack.completeVerification.mockResolvedValue({
+ success: true,
+ bot_name: "Test Bot",
+ });
+ client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined);
+ client.agents.setupSlack.validateToken.mockResolvedValue({
+ valid: slackValidationValid,
+ error: slackValidationValid ? undefined : "Invalid token",
+ });
+
+ // GitHub mocks
+ client.agents.setupGitHub.startCreation.mockResolvedValue({
+ manifest: TEST_MANIFEST,
+ github_url: TEST_GITHUB_URL,
+ session_id: TEST_SESSION_ID,
+ });
+ client.agents.setupGitHub.getCreationStatus.mockResolvedValue({
+ status: githubCreationStatus,
+ app_data:
+ githubCreationStatus === "completed" ||
+ githubCreationStatus === "app_created"
+ ? githubAppData
+ : undefined,
+ credentials:
+ githubCreationStatus === "completed"
+ ? {
+ app_id: githubAppData.id,
+ client_id: "Iv1.test123",
+ client_secret: "test-client-secret",
+ webhook_secret: "test-webhook-secret",
+ private_key: btoa("test-private-key"),
+ }
+ : undefined,
+ error:
+ githubCreationStatus === "failed" ? "Something went wrong" : undefined,
+ });
+ client.agents.setupGitHub.completeCreation.mockResolvedValue({
+ success: true,
+ app_name: githubAppData.name,
+ app_url: githubAppData.html_url,
+ install_url: `${githubAppData.html_url}/installations/new`,
+ });
+
+ // Environment variables mock
+ client.agents.env.create.mockResolvedValue({
+ id: "env-123",
+ created_at: new Date(),
+ updated_at: new Date(),
+ created_by: "user-123",
+ updated_by: "user-123",
+ key: "TEST_KEY",
+ value: "test-value",
+ secret: true,
+ target: ["preview", "production"],
+ });
+ client.agents.updateOnboarding.mockResolvedValue({
+ id: TEST_AGENT_ID,
+ organization_id: "org-123",
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ created_by: "user-123",
+ name: "Scout",
+ description: null,
+ avatar_url: null,
+ visibility: "organization",
+ active_deployment_id: null,
+ pinned: false,
+ request_url: null,
+ chat_expire_ttl: null,
+ onboarding_state: null,
+ integrations_state: null,
+ });
+}
+
+const meta: Meta = {
+ title: "Settings/Integrations/IntegrationsManager",
+ component: IntegrationsManager,
+ parameters: {
+ layout: "centered",
+ },
+ args: {
+ agentId: TEST_AGENT_ID,
+ agentName: "Scout",
+ },
+ render: (args) => (
+
+
+
+ ),
+ decorators: [withMockClient((client) => configureMockClient(client))],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+Default.storyName = "Default (All Cards)";
+
+// Settings that the mock client can read for dynamic behavior
+const interactiveSettings = {
+ slackBotTokenValid: true,
+ slackSigningSecretValid: true,
+ slackPollCount: 0,
+ githubPollCount: 0,
+};
+
+// Configure interactive mock client with dynamic behavior
+function configureInteractiveMockClient(client: MockedClient) {
+ // Slack mocks with dynamic behavior
+ client.agents.setupSlack.getWebhookUrl.mockResolvedValue({
+ webhook_url: TEST_WEBHOOK_URL,
+ });
+ client.agents.setupSlack.startVerification.mockImplementation(() => {
+ interactiveSettings.slackPollCount = 0;
+ return Promise.resolve({ webhook_url: TEST_WEBHOOK_URL });
+ });
+ client.agents.setupSlack.getVerificationStatus.mockImplementation(() => {
+ interactiveSettings.slackPollCount++;
+ const dmReceived = interactiveSettings.slackPollCount >= 3;
+ const signatureFailed =
+ dmReceived && !interactiveSettings.slackSigningSecretValid;
+ return Promise.resolve({
+ active: true,
+ started_at: new Date().toISOString(),
+ last_event_at:
+ interactiveSettings.slackPollCount > 1
+ ? new Date().toISOString()
+ : undefined,
+ dm_received: dmReceived,
+ dm_channel: dmReceived ? "D12345678" : undefined,
+ signature_failed: signatureFailed,
+ });
+ });
+ client.agents.setupSlack.completeVerification.mockResolvedValue({
+ success: true,
+ bot_name: "Scout Bot",
+ });
+ client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined);
+ client.agents.setupSlack.validateToken.mockImplementation(() =>
+ Promise.resolve({
+ valid: interactiveSettings.slackBotTokenValid,
+ error: interactiveSettings.slackBotTokenValid
+ ? undefined
+ : "Invalid bot token",
+ })
+ );
+
+ // GitHub mocks with auto-progression
+ client.agents.setupGitHub.startCreation.mockImplementation(() => {
+ interactiveSettings.githubPollCount = 0;
+ return Promise.resolve({
+ manifest: TEST_MANIFEST,
+ github_url: TEST_GITHUB_URL,
+ session_id: TEST_SESSION_ID,
+ });
+ });
+ client.agents.setupGitHub.getCreationStatus.mockImplementation(() => {
+ interactiveSettings.githubPollCount++;
+ let status: "pending" | "app_created" | "completed" = "pending";
+ if (interactiveSettings.githubPollCount >= 5) {
+ status = "completed";
+ } else if (interactiveSettings.githubPollCount >= 3) {
+ status = "app_created";
+ }
+
+ const appData = {
+ id: 12345,
+ name: "my-org-Scout",
+ html_url: "https://github.com/apps/my-org-scout",
+ slug: "my-org-scout",
+ };
+
+ return Promise.resolve({
+ status,
+ app_data: status !== "pending" ? appData : undefined,
+ credentials:
+ status === "completed"
+ ? {
+ app_id: appData.id,
+ client_id: "Iv1.test123",
+ client_secret: "test-client-secret-12345",
+ webhook_secret: "test-webhook-secret-67890",
+ private_key: btoa(
+ "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----"
+ ),
+ }
+ : undefined,
+ });
+ });
+ client.agents.setupGitHub.completeCreation.mockResolvedValue({
+ success: true,
+ app_name: "my-org-Scout",
+ app_url: "https://github.com/apps/my-org-scout",
+ install_url: "https://github.com/apps/my-org-scout/installations/new",
+ });
+
+ // Environment variables mock
+ client.agents.env.create.mockResolvedValue({
+ id: "env-123",
+ created_at: new Date(),
+ updated_at: new Date(),
+ created_by: "user-123",
+ updated_by: "user-123",
+ key: "TEST_KEY",
+ value: "test-value",
+ secret: true,
+ target: ["preview", "production"],
+ });
+ client.agents.updateOnboarding.mockResolvedValue({
+ id: TEST_AGENT_ID,
+ organization_id: "org-123",
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ created_by: "user-123",
+ name: "Scout",
+ description: null,
+ avatar_url: null,
+ visibility: "organization",
+ active_deployment_id: null,
+ pinned: false,
+ request_url: null,
+ chat_expire_ttl: null,
+ onboarding_state: null,
+ integrations_state: null,
+ });
+}
+
+function InteractiveFlowWrapper() {
+ const [slackBotTokenValid, setSlackBotTokenValid] = useState(true);
+ const [slackSigningSecretValid, setSlackSigningSecretValid] = useState(true);
+ const [key, setKey] = useState(0);
+
+ // Update global settings when state changes
+ interactiveSettings.slackBotTokenValid = slackBotTokenValid;
+ interactiveSettings.slackSigningSecretValid = slackSigningSecretValid;
+
+ const resetIntegrations = () => {
+ interactiveSettings.slackPollCount = 0;
+ interactiveSettings.githubPollCount = 0;
+ setKey((k) => k + 1);
+ };
+
+ return (
+
+
+
+
+
+
Test Controls
+
+
+
+
+
+
+ Reset All
+
+
+
+ Toggle checkboxes to simulate different API responses. GitHub will
+ auto-progress through stages after clicking "Create & install on
+ GitHub".
+
+
+
+ );
+}
+
+export const InteractiveFlow: Story = {
+ render: () => ,
+ decorators: [withMockClient(configureInteractiveMockClient)],
+};
+InteractiveFlow.storyName = "Interactive Flow";
+
+export const MixedStates: Story = {
+ args: {
+ integrationsState: {
+ llm: true,
+ github: true,
+ },
+ },
+};
+MixedStates.storyName = "Mixed States (Partial Setup)";
+
+export const AllConfigured: Story = {
+ args: {
+ integrationsState: {
+ llm: true,
+ webSearch: true,
+ github: true,
+ slack: true,
+ },
+ },
+};
+AllConfigured.storyName = "All Configured";
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.tsx
new file mode 100644
index 00000000..a77a4f5f
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import type { IntegrationsState } from "@blink.so/api";
+import { Key, Search } from "lucide-react";
+import { useState } from "react";
+import { GitHubIcon } from "@/components/icons/github";
+import { SlackIcon } from "@/components/slack-icon";
+import { GitHubIntegration } from "./github-integration";
+import { IntegrationCard } from "./integration-card";
+import { LlmIntegration } from "./llm-integration";
+import { SlackIntegration } from "./slack-integration";
+import { WebSearchIntegration } from "./web-search-integration";
+
+type ActiveSetup = "llm" | "web-search" | "github" | "slack" | null;
+
+interface IntegrationsManagerProps {
+ agentId: string;
+ agentName: string;
+ integrationsState: IntegrationsState | null;
+}
+
+export default function IntegrationsManager({
+ agentId,
+ agentName,
+ integrationsState,
+}: IntegrationsManagerProps) {
+ const [activeSetup, setActiveSetup] = useState(null);
+ // Derive initial configured status from integrationsState
+ const [configured, setConfigured] = useState({
+ llm: !!integrationsState?.llm,
+ webSearch: !!integrationsState?.webSearch,
+ github: !!integrationsState?.github,
+ slack: !!integrationsState?.slack,
+ });
+
+ const handleComplete = (integration: keyof typeof configured) => {
+ setConfigured((prev) => ({ ...prev, [integration]: true }));
+ setActiveSetup(null);
+ };
+
+ const handleCancel = () => {
+ setActiveSetup(null);
+ };
+
+ // Render active setup wizard
+ if (activeSetup === "llm") {
+ return (
+ handleComplete("llm")}
+ onCancel={handleCancel}
+ />
+ );
+ }
+
+ if (activeSetup === "web-search") {
+ return (
+ handleComplete("webSearch")}
+ onCancel={handleCancel}
+ />
+ );
+ }
+
+ if (activeSetup === "github") {
+ return (
+ handleComplete("github")}
+ onCancel={handleCancel}
+ />
+ );
+ }
+
+ if (activeSetup === "slack") {
+ return (
+ handleComplete("slack")}
+ onCancel={handleCancel}
+ />
+ );
+ }
+
+ // Render integration cards grid
+ return (
+
+
+
Integrations
+
+ Connect your agent to external services to extend its capabilities.
+
+
+
+ }
+ iconBgColor="bg-amber-500"
+ configured={configured.llm}
+ onConfigure={() => setActiveSetup("llm")}
+ />
+ }
+ iconBgColor="bg-blue-500"
+ configured={configured.webSearch}
+ onConfigure={() => setActiveSetup("web-search")}
+ />
+ }
+ iconBgColor="bg-[#24292f]"
+ configured={configured.github}
+ onConfigure={() => setActiveSetup("github")}
+ />
+ }
+ iconBgColor="bg-[#4A154B]"
+ configured={configured.slack}
+ onConfigure={() => setActiveSetup("slack")}
+ />
+
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/llm-integration.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/llm-integration.tsx
new file mode 100644
index 00000000..262bb152
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/llm-integration.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { Key } from "lucide-react";
+import {
+ getEnvVarKeyForProvider,
+ type LlmApiKeysResult,
+ LlmApiKeysSetup,
+} from "@/components/llm-api-keys-setup";
+import { EnvVarConfirmation } from "./env-var-confirmation";
+import { useIntegrationSetup } from "./use-integration-setup";
+
+interface LlmIntegrationProps {
+ agentId: string;
+ onComplete: () => void;
+ onCancel: () => void;
+}
+
+export function LlmIntegration({
+ agentId,
+ onComplete,
+ onCancel,
+}: LlmIntegrationProps) {
+ const {
+ step,
+ setStep,
+ setupResult,
+ envVars,
+ setEnvVars,
+ saving,
+ handleSave,
+ handleSetupComplete,
+ } = useIntegrationSetup({
+ agentId,
+ onComplete,
+ integrationKey: "llm",
+ successMessage: "LLM API key configured successfully",
+ });
+
+ const onSetupComplete = (result: LlmApiKeysResult) => {
+ const envVarKey = getEnvVarKeyForProvider(result.provider);
+ handleSetupComplete(result, [
+ {
+ defaultKey: envVarKey,
+ currentKey: envVarKey,
+ value: result.apiKey,
+ secret: true,
+ },
+ ]);
+ };
+
+ if (step === "confirm") {
+ return (
+ }
+ iconBgColor="bg-amber-500"
+ envVars={envVars}
+ onEnvVarsChange={setEnvVars}
+ onSave={handleSave}
+ onCancel={onCancel}
+ onBack={() => setStep("setup")}
+ saving={saving}
+ />
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/page.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/page.tsx
new file mode 100644
index 00000000..f3de8264
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/page.tsx
@@ -0,0 +1,33 @@
+import { notFound, redirect } from "next/navigation";
+import { auth } from "@/app/(auth)/auth";
+import { getAgent } from "../../../layout";
+import IntegrationsManager from "./integrations-manager";
+
+export default async function IntegrationsSettingsPage({
+ params,
+}: {
+ params: Promise<{ organization: string; agent: string }>;
+}) {
+ const session = await auth();
+ if (!session || !session?.user?.id) {
+ return redirect("/login");
+ }
+
+ const { organization: organizationName, agent: agentName } = await params;
+ const agent = await getAgent(organizationName, agentName);
+
+ const permission = agent.user_permission;
+ if (!(permission === "admin" || permission === "write")) {
+ return notFound();
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/slack-integration.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/slack-integration.tsx
new file mode 100644
index 00000000..1d9a576a
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/slack-integration.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { SlackIcon } from "@/components/slack-icon";
+import { SlackSetupWizard } from "@/components/slack-setup-wizard";
+import { EnvVarConfirmation } from "./env-var-confirmation";
+import { useIntegrationSetup } from "./use-integration-setup";
+
+interface SlackCredentials {
+ botToken: string;
+ signingSecret: string;
+}
+
+interface SlackIntegrationProps {
+ agentId: string;
+ agentName: string;
+ onComplete: () => void;
+ onCancel: () => void;
+}
+
+export function SlackIntegration({
+ agentId,
+ agentName,
+ onComplete,
+ onCancel,
+}: SlackIntegrationProps) {
+ const {
+ step,
+ setStep,
+ envVars,
+ setEnvVars,
+ saving,
+ handleSave,
+ handleSetupComplete,
+ } = useIntegrationSetup({
+ agentId,
+ onComplete,
+ integrationKey: "slack",
+ successMessage: "Slack integration configured successfully",
+ });
+
+ const onSetupComplete = (result: SlackCredentials) => {
+ handleSetupComplete(result, [
+ {
+ defaultKey: "SLACK_BOT_TOKEN",
+ currentKey: "SLACK_BOT_TOKEN",
+ value: result.botToken,
+ secret: true,
+ },
+ {
+ defaultKey: "SLACK_SIGNING_SECRET",
+ currentKey: "SLACK_SIGNING_SECRET",
+ value: result.signingSecret,
+ secret: true,
+ },
+ ]);
+ };
+
+ if (step === "confirm") {
+ return (
+ }
+ iconBgColor="bg-[#4A154B]"
+ envVars={envVars}
+ onEnvVarsChange={setEnvVars}
+ onSave={handleSave}
+ onCancel={onCancel}
+ onBack={() => setStep("setup")}
+ saving={saving}
+ />
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/use-integration-setup.ts b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/use-integration-setup.ts
new file mode 100644
index 00000000..baefc2a2
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/use-integration-setup.ts
@@ -0,0 +1,112 @@
+"use client";
+
+import type { IntegrationsState } from "@blink.so/api";
+import { useCallback, useState } from "react";
+import { toast } from "sonner";
+import { useAPIClient } from "@/lib/api-client";
+import type { EnvVarConfig } from "./env-var-confirmation";
+
+type SetupStep = "setup" | "confirm";
+
+interface UseIntegrationSetupOptions {
+ agentId: string;
+ onComplete: () => void;
+
+ /** The integration key to set in integrations_state */
+ integrationKey: keyof IntegrationsState;
+
+ /** Success message to show */
+ successMessage: string;
+}
+
+interface UseIntegrationSetupReturn {
+ step: SetupStep;
+ setStep: (step: SetupStep) => void;
+ setupResult: TSetupResult | null;
+ setSetupResult: (result: TSetupResult | null) => void;
+ envVars: EnvVarConfig[];
+ setEnvVars: (envVars: EnvVarConfig[]) => void;
+ saving: boolean;
+ handleSave: () => Promise;
+ handleSetupComplete: (result: TSetupResult, envVars: EnvVarConfig[]) => void;
+}
+
+export function useIntegrationSetup({
+ agentId,
+ onComplete,
+ integrationKey,
+ successMessage,
+}: UseIntegrationSetupOptions): UseIntegrationSetupReturn {
+ const client = useAPIClient();
+
+ const [step, setStep] = useState("setup");
+ const [setupResult, setSetupResult] = useState(null);
+ const [envVars, setEnvVars] = useState([]);
+ const [saving, setSaving] = useState(false);
+
+ const handleSetupComplete = useCallback(
+ (result: TSetupResult, newEnvVars: EnvVarConfig[]) => {
+ setSetupResult(result);
+ setEnvVars(newEnvVars);
+ setStep("confirm");
+ },
+ []
+ );
+
+ const handleSave = useCallback(async () => {
+ if (!setupResult) return;
+
+ setSaving(true);
+ try {
+ // Save env vars
+ await Promise.all(
+ envVars.map((envVar) =>
+ client.agents.env.create({
+ agent_id: agentId,
+ key: envVar.currentKey,
+ value: envVar.value,
+ secret: envVar.secret,
+ target: ["preview", "production"],
+ upsert: true,
+ })
+ )
+ );
+
+ // Update integrations_state
+ await client.agents.updateIntegrationsState(agentId, {
+ [integrationKey]: true,
+ });
+
+ toast.success(successMessage);
+ onComplete();
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Failed to save environment variables"
+ );
+ } finally {
+ setSaving(false);
+ }
+ }, [
+ agentId,
+ client,
+ envVars,
+ onComplete,
+ setupResult,
+ integrationKey,
+ successMessage,
+ ]);
+
+ return {
+ step,
+ setStep,
+ setupResult,
+ setSetupResult,
+ envVars,
+ setEnvVars,
+ saving,
+ handleSave,
+ handleSetupComplete,
+ };
+}
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/web-search-integration.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/web-search-integration.tsx
new file mode 100644
index 00000000..ec754bea
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/web-search-integration.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { Search } from "lucide-react";
+import {
+ EXA_ENV_VAR_KEY,
+ type WebSearchResult,
+ WebSearchSetup,
+} from "@/components/web-search-setup";
+import { EnvVarConfirmation } from "./env-var-confirmation";
+import { useIntegrationSetup } from "./use-integration-setup";
+
+interface WebSearchIntegrationProps {
+ agentId: string;
+ onComplete: () => void;
+ onCancel: () => void;
+}
+
+export function WebSearchIntegration({
+ agentId,
+ onComplete,
+ onCancel,
+}: WebSearchIntegrationProps) {
+ const {
+ step,
+ setStep,
+ setupResult,
+ envVars,
+ setEnvVars,
+ saving,
+ handleSave,
+ handleSetupComplete,
+ } = useIntegrationSetup({
+ agentId,
+ onComplete,
+ integrationKey: "webSearch",
+ successMessage: "Web search configured successfully",
+ });
+
+ const onSetupComplete = (result: WebSearchResult) => {
+ handleSetupComplete(result, [
+ {
+ defaultKey: EXA_ENV_VAR_KEY,
+ currentKey: EXA_ENV_VAR_KEY,
+ value: result.exaApiKey,
+ secret: true,
+ },
+ ]);
+ };
+
+ if (step === "confirm") {
+ return (
+ }
+ iconBgColor="bg-blue-500"
+ envVars={envVars}
+ onEnvVarsChange={setEnvVars}
+ onSave={handleSave}
+ onCancel={onCancel}
+ onBack={() => setStep("setup")}
+ saving={saving}
+ />
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/navigation.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/navigation.tsx
index 8f35faad..ef18fd47 100644
--- a/internal/site/app/(app)/[organization]/[agent]/settings/navigation.tsx
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/navigation.tsx
@@ -17,11 +17,17 @@ export function AgentSettingsNav() {
label: "Environment Variables",
href: `${baseHref}/env`,
},
+ {
+ value: "integrations",
+ label: "Integrations",
+ href: `${baseHref}/integrations`,
+ },
{ value: "webhooks", label: "Webhooks", href: `${baseHref}/webhooks` },
];
const getActiveTab = (pathname: string | null) => {
if (pathname?.includes("/settings/env")) return "environment";
+ if (pathname?.includes("/settings/integrations")) return "integrations";
if (pathname?.includes("/settings/webhooks")) return "webhooks";
return "general";
};
diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/page.stories.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/page.stories.tsx
new file mode 100644
index 00000000..210bb9c3
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/[agent]/settings/page.stories.tsx
@@ -0,0 +1,274 @@
+import type { Agent } from "@blink.so/api";
+import type { Meta, StoryObj } from "@storybook/react";
+import { PageContainer } from "@/components/page-header";
+import {
+ SettingsNavigation,
+ type SettingsTab,
+} from "@/components/settings-navigation";
+import { type MockedClient, withMockClient } from "@/lib/api-client.mock";
+import { AgentDeleteForm } from "./agent-delete-form";
+import { AgentSettingsForm } from "./form";
+import IntegrationsManager from "./integrations/integrations-manager";
+import { WebhooksSection } from "./webhooks/webhooks-section";
+
+const TEST_AGENT_ID = "test-agent-123";
+const TEST_ORGANIZATION_NAME = "acme";
+const TEST_AGENT_NAME = "scout";
+const TEST_WEBHOOK_URL = "https://api.blink.so/webhooks/test-webhook-id";
+const TEST_GITHUB_URL = "https://github.com/settings/apps/new";
+const TEST_MANIFEST = JSON.stringify({
+ name: "Test App",
+ url: "https://blink.so",
+});
+const TEST_SESSION_ID = "test-session-456";
+
+// Mock agent data
+const mockAgent: Agent = {
+ id: TEST_AGENT_ID,
+ organization_id: "org-123",
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ created_by: "user-123",
+ name: TEST_AGENT_NAME,
+ description: "An AI-powered code review assistant.",
+ avatar_url: null,
+ visibility: "organization",
+ active_deployment_id: "deploy-123",
+ pinned: false,
+ request_url: "https://api.blink.so/agents/test-agent-123/request",
+ chat_expire_ttl: null,
+ onboarding_state: null,
+ integrations_state: null,
+};
+
+// Configure mock client for general settings
+function configureMockClient(client: MockedClient) {
+ client.agents.update.mockResolvedValue({
+ ...mockAgent,
+ updated_at: new Date().toISOString(),
+ });
+ client.agents.delete.mockResolvedValue(undefined);
+}
+
+// Configure mock client for integrations tab
+function configureIntegrationsMockClient(client: MockedClient) {
+ // Slack mocks
+ client.agents.setupSlack.getWebhookUrl.mockResolvedValue({
+ webhook_url: TEST_WEBHOOK_URL,
+ });
+ client.agents.setupSlack.startVerification.mockResolvedValue({
+ webhook_url: TEST_WEBHOOK_URL,
+ });
+ client.agents.setupSlack.getVerificationStatus.mockResolvedValue({
+ active: true,
+ started_at: new Date().toISOString(),
+ last_event_at: undefined,
+ dm_received: false,
+ dm_channel: undefined,
+ signature_failed: false,
+ });
+ client.agents.setupSlack.completeVerification.mockResolvedValue({
+ success: true,
+ bot_name: "Test Bot",
+ });
+ client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined);
+ client.agents.setupSlack.validateToken.mockResolvedValue({
+ valid: true,
+ error: undefined,
+ });
+
+ // GitHub mocks
+ client.agents.setupGitHub.startCreation.mockResolvedValue({
+ manifest: TEST_MANIFEST,
+ github_url: TEST_GITHUB_URL,
+ session_id: TEST_SESSION_ID,
+ });
+ client.agents.setupGitHub.getCreationStatus.mockResolvedValue({
+ status: "pending",
+ app_data: undefined,
+ credentials: undefined,
+ error: undefined,
+ });
+ client.agents.setupGitHub.completeCreation.mockResolvedValue({
+ success: true,
+ app_name: "Test GitHub App",
+ app_url: "https://github.com/apps/test-github-app",
+ install_url: "https://github.com/apps/test-github-app/installations/new",
+ });
+
+ // Environment variables mock
+ client.agents.env.create.mockResolvedValue({
+ id: "env-123",
+ created_at: new Date(),
+ updated_at: new Date(),
+ created_by: "user-123",
+ updated_by: "user-123",
+ key: "TEST_KEY",
+ value: "test-value",
+ secret: true,
+ target: ["preview", "production"],
+ });
+ client.agents.updateOnboarding.mockResolvedValue({
+ id: TEST_AGENT_ID,
+ organization_id: "org-123",
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ created_by: "user-123",
+ name: "Scout",
+ description: null,
+ avatar_url: null,
+ visibility: "organization",
+ active_deployment_id: null,
+ pinned: false,
+ request_url: null,
+ chat_expire_ttl: null,
+ onboarding_state: null,
+ integrations_state: null,
+ });
+}
+
+// Mock navigation component with configurable active tab
+function MockSettingsNav({ activeTab }: { activeTab: string }) {
+ const baseHref = `/${TEST_ORGANIZATION_NAME}/${TEST_AGENT_NAME}/settings`;
+
+ const tabs: SettingsTab[] = [
+ { value: "general", label: "General", href: baseHref },
+ {
+ value: "environment",
+ label: "Environment Variables",
+ href: `${baseHref}/env`,
+ },
+ {
+ value: "integrations",
+ label: "Integrations",
+ href: `${baseHref}/integrations`,
+ },
+ { value: "webhooks", label: "Webhooks", href: `${baseHref}/webhooks` },
+ ];
+
+ return (
+ activeTab}
+ />
+ );
+}
+
+// General settings tab content
+function GeneralTabContent({ agent }: { agent: Agent }) {
+ return (
+
+
+
+
+ );
+}
+
+// Integrations tab content
+function IntegrationsTabContent({ agent }: { agent: Agent }) {
+ return (
+
+
+
+
+ );
+}
+
+// Webhooks tab content
+function WebhooksTabContent({ agent }: { agent: Agent }) {
+ return (
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Settings/AgentSettings",
+ parameters: {
+ layout: "fullscreen",
+ nextjs: {
+ appDirectory: true,
+ navigation: {
+ pathname: `/${TEST_ORGANIZATION_NAME}/${TEST_AGENT_NAME}/settings`,
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const General: Story = {
+ render: () => ,
+ decorators: [withMockClient(configureMockClient)],
+};
+
+export const Integrations: Story = {
+ render: () => ,
+ decorators: [withMockClient(configureIntegrationsMockClient)],
+};
+
+export const IntegrationsConfigured: Story = {
+ render: () => (
+
+ ),
+ decorators: [withMockClient(configureIntegrationsMockClient)],
+};
+IntegrationsConfigured.storyName = "Integrations (Configured)";
+
+export const Webhooks: Story = {
+ render: () => ,
+ decorators: [withMockClient(configureMockClient)],
+};
+
+export const WebhooksNotDeployed: Story = {
+ render: () => (
+
+ ),
+ decorators: [withMockClient(configureMockClient)],
+};
+WebhooksNotDeployed.storyName = "Webhooks (Not Deployed)";
diff --git a/internal/site/app/(app)/[organization]/layout.tsx b/internal/site/app/(app)/[organization]/layout.tsx
index cae5272c..4fee53aa 100644
--- a/internal/site/app/(app)/[organization]/layout.tsx
+++ b/internal/site/app/(app)/[organization]/layout.tsx
@@ -62,6 +62,16 @@ export const getUser = cache(async (userID: string) => {
});
export const getAgent = cache(
+ async (organizationName: string, agentName: string) => {
+ const agent = await getAgentOrNull(organizationName, agentName);
+ if (!agent) {
+ return notFound();
+ }
+ return agent;
+ }
+);
+
+export const getAgentOrNull = cache(
async (organizationName: string, agentName: string) => {
const session = await auth();
const userID = session?.user?.id;
@@ -72,7 +82,7 @@ export const getAgent = cache(
userID,
});
if (!agent) {
- return notFound();
+ return null;
}
// Get the production deployment target's request_id
const productionTarget = await db.selectAgentDeploymentTargetByName(
@@ -104,7 +114,7 @@ export const getAgent = cache(
});
// If permission is undefined, user doesn't have access
if (userPermission === undefined) {
- return notFound();
+ return null;
}
}
}
diff --git a/internal/site/app/(app)/[organization]/page.stories.tsx b/internal/site/app/(app)/[organization]/page.stories.tsx
index ae3cbd3c..2ed4f26b 100644
--- a/internal/site/app/(app)/[organization]/page.stories.tsx
+++ b/internal/site/app/(app)/[organization]/page.stories.tsx
@@ -66,6 +66,10 @@ export const Default: Story = {
chat_expire_ttl: null,
last_deployment_number: 0,
last_run_number: 0,
+ slack_verification: null,
+ github_app_setup: null,
+ onboarding_state: null,
+ integrations_state: null,
active_deployment_created_by: "1",
active_deployment_created_at: new Date(),
},
@@ -84,6 +88,10 @@ export const Default: Story = {
chat_expire_ttl: null,
last_deployment_number: 0,
last_run_number: 0,
+ slack_verification: null,
+ github_app_setup: null,
+ onboarding_state: null,
+ integrations_state: null,
active_deployment_created_by: null,
active_deployment_created_at: null,
},
diff --git a/internal/site/app/(app)/[organization]/page.tsx b/internal/site/app/(app)/[organization]/page.tsx
index d9fc8f82..91ca84e9 100644
--- a/internal/site/app/(app)/[organization]/page.tsx
+++ b/internal/site/app/(app)/[organization]/page.tsx
@@ -113,6 +113,25 @@ export default async function Page({
const isPersonal = organization.id === user.organization_id;
+ const DEFAULT_AGENT_NAME = "blink";
+
+ // Find an agent with onboarding in progress (finished === false)
+ const onboardingAgent = agents.find(
+ (a) => a.onboarding_state?.finished === false
+ );
+
+ // Redirect to onboarding if organization has no agents
+ if (agents.length === 0) {
+ return redirect(`/${organizationName}/~/onboarding/${DEFAULT_AGENT_NAME}`);
+ }
+
+ // Redirect to agent onboarding if there's a one agent being onboarded
+ if (agents.length === 1 && onboardingAgent) {
+ return redirect(
+ `/${organizationName}/~/onboarding/${onboardingAgent.name}`
+ );
+ }
+
return (
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/[agent]/page.tsx b/internal/site/app/(app)/[organization]/~/onboarding/[agent]/page.tsx
new file mode 100644
index 00000000..dea41129
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/[agent]/page.tsx
@@ -0,0 +1,62 @@
+import type { Metadata } from "next";
+import { redirect } from "next/navigation";
+import { auth } from "@/app/(auth)/auth";
+import Header from "@/components/header";
+import { getAgentOrNull, getOrganization, getUser } from "../../../layout";
+import { OrganizationNavigation } from "../../../navigation";
+import { AgentOnboardingWizard } from "./wizard";
+
+export const metadata: Metadata = {
+ title: "Setup - Blink",
+};
+
+export default async function AgentOnboardingPage({
+ params,
+}: {
+ params: Promise<{ organization: string; agent: string }>;
+}) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return redirect("/login");
+ }
+
+ const { organization: organizationName, agent: agentName } = await params;
+ const [organization, agent] = await Promise.all([
+ getOrganization(session.user.id, organizationName),
+ getAgentOrNull(organizationName, agentName),
+ ]);
+ const user = await getUser(session.user.id);
+
+ // If agent exists but is not in onboarding, redirect to agent page
+ if (agent && agent.onboarding_state?.finished !== false) {
+ return redirect(`/${organizationName}/${agentName}`);
+ }
+
+ const isPersonal = organization.id === user.organization_id;
+
+ return (
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/[agent]/wizard.tsx b/internal/site/app/(app)/[organization]/~/onboarding/[agent]/wizard.tsx
new file mode 100644
index 00000000..e2c34b8f
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/[agent]/wizard.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import type { OnboardingState } from "@blink.so/api";
+import { useAPIClient } from "@/lib/api-client";
+import { type AgentInfo, WizardContent } from "../components/wizard-content";
+
+export type { OnboardingState };
+export type OnboardingStep = OnboardingState["currentStep"];
+
+export function AgentOnboardingWizard({
+ organizationId,
+ organizationName,
+ agentName,
+ agent,
+}: {
+ organizationId: string;
+ organizationName: string;
+ agentName: string;
+ /** Existing agent (for resuming onboarding) */
+ agent?: AgentInfo;
+}) {
+ const client = useAPIClient();
+
+ return (
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/components/progress-indicator.tsx b/internal/site/app/(app)/[organization]/~/onboarding/components/progress-indicator.tsx
new file mode 100644
index 00000000..80a8fcd4
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/components/progress-indicator.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { Check } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { OnboardingStep } from "./wizard-content";
+
+const stepLabels: Record
= {
+ welcome: "Welcome",
+ "llm-api-keys": "LLM",
+ "github-setup": "GitHub",
+ "slack-setup": "Slack",
+ "web-search": "Web Search",
+ deploying: "Deploy",
+ success: "Success",
+};
+
+interface ProgressIndicatorProps {
+ steps: OnboardingStep[];
+ currentStep: OnboardingStep;
+ onStepClick?: (step: OnboardingStep) => void;
+ /** When true, only the welcome step is clickable */
+ welcomeOnly?: boolean;
+}
+
+export function ProgressIndicator({
+ steps,
+ currentStep,
+ onStepClick,
+ welcomeOnly = false,
+}: ProgressIndicatorProps) {
+ const currentIndex = steps.indexOf(currentStep);
+
+ return (
+
+ {steps.map((step, index) => {
+ const isComplete = index < currentIndex;
+ const isCurrent = index === currentIndex;
+ const isDisabled = welcomeOnly && step !== "welcome";
+
+ return (
+
+
!isDisabled && onStepClick?.(step)}
+ >
+
+ {isComplete ? : index + 1}
+
+
+ {stepLabels[step] || step}
+
+
+ {index < steps.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/components/wizard-content.tsx b/internal/site/app/(app)/[organization]/~/onboarding/components/wizard-content.tsx
new file mode 100644
index 00000000..592815b6
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/components/wizard-content.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import type Client from "@blink.so/api";
+import type { IntegrationsState, OnboardingState } from "@blink.so/api";
+import { useRouter } from "next/navigation";
+import { useCallback, useState } from "react";
+import { DeployingStep } from "../steps/deploying";
+import { GitHubSetupStep } from "../steps/github-setup";
+import { LlmApiKeysStep } from "../steps/llm-api-keys";
+import { SlackSetupStep } from "../steps/slack-setup";
+import { SuccessStep } from "../steps/success";
+import { WebSearchStep } from "../steps/web-search";
+import { WelcomeStep } from "../steps/welcome";
+import { ProgressIndicator } from "./progress-indicator";
+
+export type { OnboardingState };
+export type OnboardingStep = OnboardingState["currentStep"];
+
+export interface AgentInfo {
+ id: string;
+ name: string;
+ onboarding_state: OnboardingState;
+}
+
+interface WizardContentProps {
+ organizationId: string;
+ organizationName: string;
+ client: Client;
+ /** Agent with existing onboarding state (for resuming onboarding) */
+ initialAgent?: AgentInfo;
+ /** Name for the agent to create (defaults to "blink") */
+ agentName?: string;
+}
+
+export function WizardContent({
+ organizationId,
+ organizationName,
+ client,
+ initialAgent,
+ agentName = "blink",
+}: WizardContentProps) {
+ const router = useRouter();
+
+ const [agentInfo, setAgentInfo] = useState(
+ initialAgent
+ );
+
+ const [state, setState] = useState(() => {
+ if (initialAgent) {
+ return initialAgent.onboarding_state;
+ }
+ return { currentStep: "welcome", finished: false };
+ });
+
+ const updateOnboardingState = useCallback(
+ async (updates: Partial) => {
+ const newState = { ...state, ...updates };
+ setState(newState);
+
+ if (agentInfo) {
+ try {
+ await client.agents.updateOnboarding(agentInfo.id, newState);
+ } catch (error) {
+ // biome-ignore lint/suspicious/noConsole: useful for debugging
+ console.error("Failed to update onboarding state:", error);
+ }
+ }
+ },
+ [state, agentInfo, client]
+ );
+
+ const goToStep = useCallback(
+ async (step: OnboardingStep) => {
+ await updateOnboardingState({ currentStep: step });
+ },
+ [updateOnboardingState]
+ );
+
+ const clearAndRedirect = useCallback(async () => {
+ if (agentInfo) {
+ const integrationsState: IntegrationsState = {};
+ if (state.llm?.apiKey) integrationsState.llm = true;
+ if (state.github?.appId) integrationsState.github = true;
+ if (state.slack?.botToken) integrationsState.slack = true;
+ if (state.webSearch?.apiKey) integrationsState.webSearch = true;
+
+ try {
+ await client.agents.updateIntegrationsState(
+ agentInfo.id,
+ integrationsState
+ );
+ } catch (error) {
+ // biome-ignore lint/suspicious/noConsole: useful for debugging
+ console.error("Failed to update integrations state:", error);
+ }
+
+ try {
+ await client.agents.clearOnboarding(agentInfo.id);
+ } catch (error) {
+ // biome-ignore lint/suspicious/noConsole: useful for debugging
+ console.error("Failed to clear onboarding state:", error);
+ }
+ }
+ const redirectName = agentInfo?.name ?? agentName;
+ router.push(`/${organizationName}/${redirectName}`);
+ }, [agentInfo, client, router, organizationName, agentName, state]);
+
+ const handleAgentCreated = useCallback((agent: AgentInfo) => {
+ setAgentInfo(agent);
+ setState(agent.onboarding_state);
+ }, []);
+
+ const steps: OnboardingStep[] = [
+ "welcome",
+ "llm-api-keys",
+ "github-setup",
+ "slack-setup",
+ "web-search",
+ "deploying",
+ "success",
+ ];
+
+ // Effective agent ID and name - may be undefined if agent not yet created
+ const effectiveAgentId = agentInfo?.id;
+ const effectiveAgentName = agentInfo?.name ?? agentName;
+
+ return (
+
+
goToStep(step as OnboardingStep)}
+ welcomeOnly={!effectiveAgentId}
+ />
+
+
+ {state.currentStep === "welcome" && (
+ goToStep("llm-api-keys")}
+ client={client}
+ organizationId={organizationId}
+ existingAgentId={effectiveAgentId}
+ onAgentCreated={handleAgentCreated}
+ agentName={agentName}
+ />
+ )}
+
+ {state.currentStep === "llm-api-keys" && (
+ {
+ updateOnboardingState({
+ llm: { ...state.llm, ...values },
+ currentStep: "github-setup",
+ });
+ }}
+ onSkip={() => goToStep("github-setup")}
+ onBack={() => goToStep("welcome")}
+ />
+ )}
+
+ {state.currentStep === "github-setup" && effectiveAgentId && (
+ {
+ updateOnboardingState({ github, currentStep: "slack-setup" });
+ }}
+ onSkip={() => goToStep("slack-setup")}
+ onBack={() => goToStep("llm-api-keys")}
+ />
+ )}
+
+ {state.currentStep === "slack-setup" && effectiveAgentId && (
+ {
+ updateOnboardingState({ slack, currentStep: "web-search" });
+ }}
+ onSkip={() => goToStep("web-search")}
+ onBack={() => goToStep("github-setup")}
+ />
+ )}
+
+ {state.currentStep === "web-search" && (
+ {
+ updateOnboardingState({
+ webSearch: { provider: "exa", apiKey },
+ currentStep: "deploying",
+ });
+ }}
+ onSkip={() => goToStep("deploying")}
+ onBack={() => goToStep("slack-setup")}
+ />
+ )}
+
+ {state.currentStep === "deploying" && effectiveAgentId && (
+ {
+ goToStep("success");
+ }}
+ />
+ )}
+
+ {state.currentStep === "success" && (
+
+ )}
+
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/llm-providers.ts b/internal/site/app/(app)/[organization]/~/onboarding/llm-providers.ts
new file mode 100644
index 00000000..01c0855a
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/llm-providers.ts
@@ -0,0 +1,46 @@
+export type AIProvider = "anthropic" | "openai" | "vercel";
+
+export interface LlmProvider {
+ id: AIProvider;
+ name: string;
+ description: string;
+ placeholder: string;
+ helpUrl: string;
+ createKeyText: string;
+ envVarKey: string;
+}
+
+export const LLM_PROVIDERS: LlmProvider[] = [
+ {
+ id: "anthropic",
+ name: "Anthropic",
+ description: "Claude models",
+ placeholder: "sk-ant-...",
+ helpUrl: "https://console.anthropic.com/settings/keys",
+ createKeyText: "Create Anthropic API Key",
+ envVarKey: "ANTHROPIC_API_KEY",
+ },
+ {
+ id: "openai",
+ name: "OpenAI",
+ description: "GPT models",
+ placeholder: "sk-...",
+ helpUrl: "https://platform.openai.com/api-keys",
+ createKeyText: "Create OpenAI API Key",
+ envVarKey: "OPENAI_API_KEY",
+ },
+ {
+ id: "vercel",
+ name: "Vercel AI Gateway",
+ description: "Unified gateway for multiple AI providers",
+ placeholder: "vck_...",
+ helpUrl: "https://vercel.com/ai-gateway",
+ createKeyText: "Create Vercel AI Gateway API Key",
+ envVarKey: "VERCEL_AI_GATEWAY_API_KEY",
+ },
+];
+
+export function getEnvVarKeyForProvider(provider: AIProvider): string {
+ const p = LLM_PROVIDERS.find((p) => p.id === provider);
+ return p?.envVarKey || "AI_API_KEY";
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.stories.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.stories.tsx
new file mode 100644
index 00000000..814b5bfc
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.stories.tsx
@@ -0,0 +1,86 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { withMockClient } from "@/lib/api-client.mock";
+import { DeployingStep } from "./deploying";
+
+const noop = () => {};
+
+const meta: Meta = {
+ title: "Onboarding/DeployingStep",
+ component: DeployingStep,
+ parameters: {
+ layout: "centered",
+ },
+ args: {
+ organizationId: "org-123",
+ agentId: "agent-456",
+ goToStep: noop,
+ onSuccess: noop,
+ },
+ decorators: [
+ withMockClient(),
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const SummaryEmpty: Story = {
+ args: {
+ initialStatus: "summary",
+ },
+};
+
+export const SummaryAllConfigured: Story = {
+ args: {
+ initialStatus: "summary",
+ llm: {
+ provider: "anthropic",
+ apiKey: "sk-ant-xxx",
+ },
+ webSearch: {
+ provider: "exa",
+ apiKey: "exa-xxx",
+ },
+ github: {
+ appName: "Scout",
+ appUrl: "https://github.com/apps/scout",
+ installUrl: "https://github.com/apps/scout/installations/new",
+ },
+ slack: {
+ botToken: "xoxb-xxx",
+ signingSecret: "xxx",
+ },
+ },
+};
+
+export const SummaryPartial: Story = {
+ args: {
+ initialStatus: "summary",
+ llm: {
+ provider: "openai",
+ apiKey: "sk-xxx",
+ },
+ github: {
+ appName: "MyApp",
+ appUrl: "https://github.com/apps/myapp",
+ installUrl: "https://github.com/apps/myapp/installations/new",
+ },
+ },
+};
+
+export const Deploying: Story = {
+ args: {
+ initialStatus: "deploying",
+ },
+};
+
+export const ErrorState: Story = {
+ args: {
+ initialStatus: "error",
+ },
+};
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.tsx
new file mode 100644
index 00000000..4c62ef68
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.tsx
@@ -0,0 +1,430 @@
+"use client";
+
+import type { LucideIcon } from "lucide-react";
+import {
+ AlertCircle,
+ ArrowLeft,
+ Check,
+ Github,
+ Key,
+ Loader2,
+ Rocket,
+ Search,
+} from "lucide-react";
+import type { ComponentType } from "react";
+import { useRef, useState } from "react";
+import { toast } from "sonner";
+import { OnboardingStepHeader } from "@/components/onboarding-step-header";
+import { SlackIcon } from "@/components/slack-icon";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { useAPIClient } from "@/lib/api-client";
+import type { OnboardingStep } from "../wizard";
+
+type Status = "summary" | "deploying" | "error";
+
+interface ConfigItem {
+ id: string;
+ label: string;
+ configured: boolean;
+ value?: string;
+ notConfiguredDescription: string;
+ icon?: LucideIcon;
+ IconComponent?: ComponentType<{ className?: string }>;
+ step: OnboardingStep;
+}
+
+function DeployingInProgress() {
+ return (
+
+ );
+}
+
+function DeployingError({
+ message,
+ onBack,
+}: {
+ message?: string;
+ onBack: () => void;
+}) {
+ return (
+
+
+
+
+ {message || "Something went wrong during deployment."} Please
+ check the server logs.
+ >
+ }
+ size="lg"
+ />
+
+ Go Back
+
+
+
+
+ );
+}
+
+function DeployingSummary({
+ configItems,
+ onDeploy,
+ onBack,
+ goToStep,
+}: {
+ configItems: ConfigItem[];
+ onDeploy: () => void;
+ onBack: () => void;
+ goToStep: (step: OnboardingStep) => void;
+}) {
+ return (
+
+
+
+
+
+ {configItems.map((item) => {
+ const IconComponent = item.IconComponent;
+ const LucideIcon = item.icon;
+ return (
+
+
+ {item.configured ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {IconComponent ? (
+
+ ) : LucideIcon ? (
+
+ ) : null}
+ {item.label}
+
+ {!item.configured && (
+
+ {item.notConfiguredDescription}
+
+ )}
+
+
+
+ {item.configured ? (
+
+ {item.value}
+
+ ) : (
+
+ Not configured
+
+ )}
+ goToStep(item.step)}
+ >
+ {item.configured ? "Edit" : "Set up"}
+
+
+
+ );
+ })}
+
+
+ {configItems.some((item) => !item.configured) && (
+
+ You may still deploy the agent, but its functionality will be
+ limited.
+
+ )}
+
+
+
+
+ Back
+
+
+
+ Deploy
+
+
+
+
+
+ );
+}
+
+interface DeployingStepProps {
+ organizationId: string;
+ agentId: string;
+ slack?: {
+ botToken: string;
+ signingSecret: string;
+ };
+ llm?: {
+ provider?: "anthropic" | "openai" | "vercel";
+ apiKey?: string;
+ };
+ webSearch?: {
+ provider?: "exa";
+ apiKey?: string;
+ };
+ github?: {
+ appName: string;
+ appUrl: string;
+ installUrl: string;
+ appId?: number;
+ clientId?: string;
+ clientSecret?: string;
+ webhookSecret?: string;
+ privateKey?: string;
+ };
+ goToStep: (step: OnboardingStep) => void;
+ onSuccess: (agentId: string) => void;
+ /** Initial status for stories */
+ initialStatus?: Status;
+}
+
+const providerNames: Record = {
+ anthropic: "Anthropic",
+ openai: "OpenAI",
+ vercel: "Vercel AI Gateway",
+};
+
+export function DeployingStep({
+ organizationId,
+ agentId,
+ slack,
+ llm,
+ webSearch,
+ github,
+ goToStep,
+ onSuccess,
+ initialStatus = "summary",
+}: DeployingStepProps) {
+ const client = useAPIClient();
+ const [status, setStatus] = useState(initialStatus);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const hasStartedRef = useRef(false);
+
+ const deploy = async () => {
+ if (hasStartedRef.current) return;
+ hasStartedRef.current = true;
+ setStatus("deploying");
+
+ try {
+ // Download the agent files
+ const downloadResult = await client.onboarding.downloadAgent({
+ organization_id: organizationId,
+ });
+
+ // Build environment variables
+ const env: Array<{ key: string; value: string; secret: boolean }> = [];
+
+ if (slack?.botToken) {
+ env.push({
+ key: "SLACK_BOT_TOKEN",
+ value: slack.botToken,
+ secret: true,
+ });
+ }
+ if (slack?.signingSecret) {
+ env.push({
+ key: "SLACK_SIGNING_SECRET",
+ value: slack.signingSecret,
+ secret: true,
+ });
+ }
+ if (webSearch?.apiKey) {
+ env.push({
+ key: "EXA_API_KEY",
+ value: webSearch.apiKey,
+ secret: true,
+ });
+ }
+ // Set the appropriate API key based on the selected provider
+ if (llm?.apiKey && llm?.provider) {
+ const envKeyMap: Record = {
+ anthropic: "ANTHROPIC_API_KEY",
+ openai: "OPENAI_API_KEY",
+ vercel: "AI_GATEWAY_API_KEY",
+ };
+ env.push({
+ key: envKeyMap[llm.provider],
+ value: llm.apiKey,
+ secret: true,
+ });
+ }
+ if (
+ github?.appId &&
+ github.clientId &&
+ github.clientSecret &&
+ github.webhookSecret &&
+ github.privateKey
+ ) {
+ env.push({
+ key: "GITHUB_APP_ID",
+ value: github.appId.toString(),
+ secret: false,
+ });
+ env.push({
+ key: "GITHUB_CLIENT_ID",
+ value: github.clientId,
+ secret: false,
+ });
+ env.push({
+ key: "GITHUB_CLIENT_SECRET",
+ value: github.clientSecret,
+ secret: true,
+ });
+ env.push({
+ key: "GITHUB_WEBHOOK_SECRET",
+ value: github.webhookSecret,
+ secret: true,
+ });
+ env.push({
+ key: "GITHUB_PRIVATE_KEY",
+ value: github.privateKey,
+ secret: true,
+ });
+ }
+
+ // Set environment variables on the existing agent
+ const envResults = await Promise.allSettled(
+ env.map(async (variable) => {
+ await client.agents.env.create({
+ agent_id: agentId,
+ key: variable.key,
+ value: variable.value,
+ secret: variable.secret,
+ upsert: true,
+ });
+ })
+ );
+ const errEnvResults = envResults.filter(
+ (result) => result.status === "rejected"
+ );
+ if (errEnvResults.length > 0) {
+ throw new Error(
+ `Failed to set environment variables: ${errEnvResults.map((result) => result.reason).join(", ")}`
+ );
+ }
+
+ // Deploy the agent with the downloaded files
+ await client.agents.deployments.create({
+ agent_id: agentId,
+ output_files: downloadResult.output_files,
+ source_files: downloadResult.source_files,
+ entrypoint: downloadResult.entrypoint,
+ target: "production",
+ });
+
+ onSuccess(agentId);
+ } catch (error) {
+ setStatus("error");
+ hasStartedRef.current = false;
+ const message =
+ error instanceof Error ? error.message : "Deployment failed";
+ setErrorMessage(message);
+ toast.error(message);
+ }
+ };
+
+ // Configuration items for the summary
+ const configItems: ConfigItem[] = [
+ {
+ id: "llm",
+ label: "LLM API Key",
+ configured: !!llm?.apiKey,
+ value: llm?.provider ? providerNames[llm.provider] : undefined,
+ notConfiguredDescription: "The agent will not be able to respond.",
+ icon: Key,
+ step: "llm-api-keys",
+ },
+ {
+ id: "github",
+ label: "GitHub",
+ configured: !!github?.appName,
+ value: "Connected",
+ notConfiguredDescription:
+ "The agent will not be able to access GitHub repositories.",
+ icon: Github,
+ step: "github-setup",
+ },
+ {
+ id: "slack",
+ label: "Slack",
+ configured: !!slack?.botToken,
+ value: "Connected",
+ notConfiguredDescription: "The agent will not be available in Slack.",
+ IconComponent: SlackIcon,
+ step: "slack-setup",
+ },
+ {
+ id: "web-search",
+ label: "Web Search",
+ configured: !!webSearch?.apiKey,
+ value: "Exa",
+ notConfiguredDescription: "The agent will not be able to search the web.",
+ icon: Search,
+ step: "web-search",
+ },
+ ];
+
+ if (status === "error") {
+ return (
+ setStatus("summary")}
+ />
+ );
+ }
+
+ if (status === "deploying") {
+ return ;
+ }
+
+ return (
+ goToStep("web-search")}
+ goToStep={goToStep}
+ />
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/github-setup.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/github-setup.tsx
new file mode 100644
index 00000000..7f6ff7b2
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/github-setup.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { ArrowRight, Github } from "lucide-react";
+import { useState } from "react";
+import {
+ type GitHubAppCredentials,
+ GitHubSetupWizard,
+} from "@/components/github-setup-wizard";
+import { OnboardingStepFooter } from "@/components/onboarding-step-footer";
+import { OnboardingStepHeader } from "@/components/onboarding-step-header";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+
+export interface GitHubSetupResult {
+ appName: string;
+ appUrl: string;
+ installUrl: string;
+ appId: number;
+ clientId: string;
+ clientSecret: string;
+ webhookSecret: string;
+ privateKey: string;
+}
+
+interface GitHubSetupStepProps {
+ agentId: string;
+ agentName: string;
+ onComplete: (result: GitHubSetupResult) => void;
+ onSkip: () => void;
+ onBack?: () => void;
+}
+
+export function GitHubSetupStep({
+ agentId,
+ agentName,
+ onComplete,
+ onSkip,
+ onBack,
+}: GitHubSetupStepProps) {
+ const [showWizard, setShowWizard] = useState(false);
+
+ const handleWizardComplete = (result: {
+ appName: string;
+ appUrl: string;
+ installUrl: string;
+ credentials: GitHubAppCredentials;
+ }) => {
+ onComplete({
+ appName: result.appName,
+ appUrl: result.appUrl,
+ installUrl: result.installUrl,
+ appId: result.credentials.appId,
+ clientId: result.credentials.clientId,
+ clientSecret: result.credentials.clientSecret,
+ webhookSecret: result.credentials.webhookSecret,
+ privateKey: result.credentials.privateKey,
+ });
+ };
+
+ if (!showWizard) {
+ return (
+
+
+
+ setShowWizard(true)}>
+ Create GitHub App
+
+
+
+ You can also set this up later in Settings > Integrations
+
+
+
+
+ );
+ }
+
+ return (
+ setShowWizard(false)}
+ onSkip={onSkip}
+ />
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/llm-api-keys.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/llm-api-keys.tsx
new file mode 100644
index 00000000..4ed76078
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/llm-api-keys.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import {
+ type AIProvider,
+ LlmApiKeysSetup,
+} from "@/components/llm-api-keys-setup";
+
+interface LlmApiKeysStepProps {
+ initialValues?: {
+ provider?: AIProvider;
+ apiKey?: string;
+ };
+ onContinue: (values: { provider?: AIProvider; apiKey?: string }) => void;
+ onSkip: () => void;
+ onBack: () => void;
+}
+
+export function LlmApiKeysStep({
+ initialValues,
+ onContinue,
+ onSkip,
+ onBack,
+}: LlmApiKeysStepProps) {
+ return (
+
+ onContinue({ provider: result.provider, apiKey: result.apiKey })
+ }
+ onBack={onBack}
+ onSkip={onSkip}
+ />
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/slack-setup.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/slack-setup.tsx
new file mode 100644
index 00000000..2010f8cf
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/slack-setup.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { ArrowRight } from "lucide-react";
+import { useState } from "react";
+import { OnboardingStepFooter } from "@/components/onboarding-step-footer";
+import { OnboardingStepHeader } from "@/components/onboarding-step-header";
+import { SlackIcon } from "@/components/slack-icon";
+import { SlackSetupWizard } from "@/components/slack-setup-wizard";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+
+interface SlackSetupStepProps {
+ agentId: string;
+ agentName: string;
+ onComplete: (slack: { botToken: string; signingSecret: string }) => void;
+ onSkip: () => void;
+ onBack: () => void;
+}
+
+export function SlackSetupStep({
+ agentId,
+ agentName,
+ onComplete,
+ onSkip,
+ onBack,
+}: SlackSetupStepProps) {
+ const [showWizard, setShowWizard] = useState(false);
+
+ if (!showWizard) {
+ return (
+
+
+
+ setShowWizard(true)}>
+ Connect to Slack
+
+
+
+ You can also set this up later in Settings > Integrations
+
+
+
+
+ );
+ }
+
+ return (
+ setShowWizard(false)}
+ onSkip={onSkip}
+ />
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/success.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/success.tsx
new file mode 100644
index 00000000..d37f137d
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/success.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { ArrowRight, CheckCircle2 } from "lucide-react";
+import { OnboardingStepHeader } from "@/components/onboarding-step-header";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+
+interface SuccessStepProps {
+ agentName: string;
+ onFinish: () => void;
+}
+
+export function SuccessStep({ agentName, onFinish }: SuccessStepProps) {
+ return (
+
+
+
+
+ Your agent {agentName} has been successfully
+ deployed and is ready to use.
+ >
+ }
+ size="lg"
+ />
+
+
Next Steps
+
+ Start a chat with your agent to test it out
+ Configure additional environment variables in settings
+ Set up webhooks for GitHub and Slack integrations
+
+
+
+
+ Go to Agent
+
+
+
+
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/web-search.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/web-search.tsx
new file mode 100644
index 00000000..7fd933b5
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/web-search.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { WebSearchSetup } from "@/components/web-search-setup";
+
+interface WebSearchStepProps {
+ initialValue?: string;
+ onContinue: (exaApiKey?: string) => void;
+ onSkip: () => void;
+ onBack: () => void;
+}
+
+export function WebSearchStep({
+ initialValue,
+ onContinue,
+ onSkip,
+ onBack,
+}: WebSearchStepProps) {
+ return (
+ onContinue(result.exaApiKey)}
+ onBack={onBack}
+ onSkip={onSkip}
+ />
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/welcome.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/welcome.tsx
new file mode 100644
index 00000000..2b700456
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/welcome.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import type Client from "@blink.so/api";
+import type { OnboardingState } from "@blink.so/api";
+import { Bot, Github, Globe, Loader2, MessageSquare } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import { OnboardingStepHeader } from "@/components/onboarding-step-header";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+
+interface AgentInfo {
+ id: string;
+ name: string;
+ onboarding_state: OnboardingState;
+}
+
+interface WelcomeStepProps {
+ onContinue: () => void;
+ client: Client;
+ organizationId: string;
+ existingAgentId?: string;
+ onAgentCreated: (agent: AgentInfo) => void;
+ /** Name for the agent to create (defaults to "blink") */
+ agentName?: string;
+}
+
+export function WelcomeStep({
+ onContinue,
+ client,
+ organizationId,
+ existingAgentId,
+ onAgentCreated,
+ agentName = "blink",
+}: WelcomeStepProps) {
+ const [loading, setLoading] = useState(false);
+
+ const handleGetStarted = async () => {
+ // If agent already exists, just continue
+ if (existingAgentId) {
+ onContinue();
+ return;
+ }
+
+ setLoading(true);
+ try {
+ // Create agent
+ const initialOnboardingState: OnboardingState = {
+ currentStep: "welcome",
+ finished: false,
+ };
+
+ const agent = await client.agents.create({
+ organization_id: organizationId,
+ name: agentName,
+ onboarding_state: initialOnboardingState,
+ });
+
+ if (!agent.onboarding_state) {
+ throw new Error(
+ "Onboarding state on new agent not found - this should never happen"
+ );
+ }
+
+ onAgentCreated({
+ id: agent.id,
+ name: agent.name,
+ onboarding_state: agent.onboarding_state,
+ });
+
+ onContinue();
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to create agent"
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
GitHub Integration
+
+ Review PRs, respond to issues, and receive webhooks
+
+
+
+
+
+
+
Slack Integration
+
+ Chat with your agent directly in Slack
+
+
+
+
+
+
+
Web Search
+
+ Search the web for up-to-date information
+
+
+
+
+
+
+ {loading ? (
+ <>
+
+ Setting up...
+ >
+ ) : (
+ "Get Started"
+ )}
+
+
+
+ );
+}
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/wizard.stories.tsx b/internal/site/app/(app)/[organization]/~/onboarding/wizard.stories.tsx
new file mode 100644
index 00000000..4c1e553f
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/wizard.stories.tsx
@@ -0,0 +1,342 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { type MockedClient, withMockClient } from "@/lib/api-client.mock";
+import { type AgentInfo, OnboardingWizard } from "./wizard";
+
+const TEST_ORGANIZATION_ID = "org-123";
+const TEST_ORGANIZATION_NAME = "test-org";
+const TEST_AGENT_ID = "agent-456";
+const TEST_FILE_ID = "file-789";
+const TEST_WEBHOOK_URL = "https://api.blink.so/webhooks/slack/test-webhook-id";
+const TEST_GITHUB_SESSION_ID = "github-session-123";
+const TEST_GITHUB_MANIFEST_URL =
+ "https://github.com/settings/apps/new?manifest=...";
+
+// Track state across mocked API calls
+const mockState = {
+ pollCount: 0,
+ githubPollCount: 0,
+};
+
+function configureMockClient(
+ client: MockedClient,
+ options?: { hangDeployment?: boolean }
+) {
+ const { hangDeployment = false } = options || {};
+
+ // Download agent
+ client.onboarding.downloadAgent.mockResolvedValue({
+ output_files: [{ path: "main.js", id: TEST_FILE_ID }],
+ source_files: [{ path: "index.ts", id: "src-file-123" }],
+ entrypoint: "main.js",
+ });
+
+ // Create agent
+ client.agents.create.mockResolvedValue({
+ id: TEST_AGENT_ID,
+ organization_id: TEST_ORGANIZATION_ID,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ created_by: "user-123",
+ name: "blink",
+ description: "AI agent with GitHub, Slack, and web search integrations",
+ avatar_url: null,
+ visibility: "organization",
+ active_deployment_id: null,
+ pinned: false,
+ request_url: null,
+ chat_expire_ttl: null,
+ onboarding_state: { currentStep: "welcome" },
+ integrations_state: null,
+ });
+
+ // Validate Slack token
+ client.agents.setupSlack.validateToken.mockResolvedValue({
+ valid: true,
+ });
+
+ // GitHub setup
+ client.agents.setupGitHub.startCreation.mockImplementation(() => {
+ mockState.githubPollCount = 0;
+ return Promise.resolve({
+ manifest_url: TEST_GITHUB_MANIFEST_URL,
+ manifest: "{}",
+ github_url: TEST_GITHUB_MANIFEST_URL,
+ session_id: TEST_GITHUB_SESSION_ID,
+ });
+ });
+
+ client.agents.setupGitHub.getCreationStatus.mockImplementation(() => {
+ mockState.githubPollCount++;
+ const completed = mockState.githubPollCount >= 3;
+ return Promise.resolve({
+ status: completed ? "completed" : "pending",
+ app_data: completed
+ ? {
+ id: 12345,
+ name: "Scout",
+ html_url: "https://github.com/apps/scout",
+ slug: "scout",
+ }
+ : undefined,
+ credentials: completed
+ ? {
+ app_id: 12345,
+ client_id: "test-client-id",
+ client_secret: "test-client-secret",
+ webhook_secret: "test-webhook-secret",
+ private_key: btoa("test-private-key"),
+ }
+ : undefined,
+ });
+ });
+
+ client.agents.setupGitHub.completeCreation.mockResolvedValue({
+ success: true,
+ app_name: "Scout",
+ app_url: "https://github.com/apps/scout",
+ install_url: "https://github.com/apps/scout/installations/new",
+ });
+
+ // Slack setup
+ client.agents.setupSlack.getWebhookUrl.mockResolvedValue({
+ webhook_url: TEST_WEBHOOK_URL,
+ });
+
+ client.agents.setupSlack.startVerification.mockImplementation(() => {
+ mockState.pollCount = 0;
+ return Promise.resolve({ webhook_url: TEST_WEBHOOK_URL });
+ });
+
+ client.agents.setupSlack.getVerificationStatus.mockImplementation(() => {
+ mockState.pollCount++;
+ const dmReceived = mockState.pollCount >= 3;
+ return Promise.resolve({
+ active: true,
+ started_at: new Date().toISOString(),
+ last_event_at:
+ mockState.pollCount > 1 ? new Date().toISOString() : undefined,
+ dm_received: dmReceived,
+ dm_channel: dmReceived ? "D12345678" : undefined,
+ signature_failed: false,
+ });
+ });
+
+ client.agents.setupSlack.completeVerification.mockResolvedValue({
+ success: true,
+ bot_name: "Scout Bot",
+ });
+
+ client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined);
+
+ // Environment variables
+ client.agents.env.create.mockResolvedValue({
+ id: "env-123",
+ created_at: new Date(),
+ updated_at: new Date(),
+ created_by: "user-123",
+ updated_by: "user-123",
+ key: "TEST_KEY",
+ value: "test-value",
+ secret: false,
+ target: ["preview", "production"],
+ });
+
+ // Deployments
+ if (hangDeployment) {
+ client.agents.deployments.create.mockImplementation(
+ () => new Promise(() => {})
+ );
+ } else {
+ client.agents.deployments.create.mockResolvedValue({
+ id: "deployment-123",
+ agent_id: TEST_AGENT_ID,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ created_by: "user-123",
+ created_from: "cli",
+ status: "success",
+ number: 1,
+ source_files: [{ path: "index.ts", id: "src-file-123" }],
+ output_files: [{ path: "main.js", id: TEST_FILE_ID }],
+ target: "production",
+ error_message: null,
+ user_message: null,
+ platform: "lambda",
+ platform_memory_mb: 512,
+ platform_region: null,
+ });
+ }
+
+ // Update onboarding
+ client.agents.updateOnboarding.mockResolvedValue({
+ id: TEST_AGENT_ID,
+ organization_id: TEST_ORGANIZATION_ID,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ created_by: "user-123",
+ name: "blink",
+ description: null,
+ avatar_url: null,
+ visibility: "organization",
+ active_deployment_id: null,
+ pinned: false,
+ request_url: null,
+ chat_expire_ttl: null,
+ onboarding_state: null,
+ integrations_state: null,
+ });
+}
+
+const meta: Meta = {
+ title: "Onboarding/OnboardingWizard",
+ component: OnboardingWizard,
+ parameters: {
+ layout: "fullscreen",
+ nextjs: {
+ appDirectory: true,
+ navigation: {
+ push: () => {},
+ },
+ },
+ },
+ args: {
+ organizationId: TEST_ORGANIZATION_ID,
+ organizationName: TEST_ORGANIZATION_NAME,
+ },
+ decorators: [
+ withMockClient((client) => configureMockClient(client)),
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Base agent for steps that need an existing agent
+const baseAgent: AgentInfo = {
+ id: TEST_AGENT_ID,
+ name: "blink",
+ onboarding_state: { currentStep: "welcome", finished: false },
+};
+
+export const FullFlow: Story = {};
+FullFlow.storyName = "Full Flow (from Welcome)";
+
+export const Step1_Welcome: Story = {};
+Step1_Welcome.storyName = "Step 1: Welcome";
+
+export const Step2_LlmApiKeys: Story = {
+ args: {
+ agent: {
+ ...baseAgent,
+ onboarding_state: { currentStep: "llm-api-keys", finished: false },
+ },
+ },
+};
+Step2_LlmApiKeys.storyName = "Step 2: LLM API Keys";
+
+export const Step3_GitHubSetup: Story = {
+ args: {
+ agent: {
+ ...baseAgent,
+ onboarding_state: { currentStep: "github-setup", finished: false },
+ },
+ },
+};
+Step3_GitHubSetup.storyName = "Step 3: GitHub Setup";
+
+export const Step4_SlackSetup: Story = {
+ args: {
+ agent: {
+ ...baseAgent,
+ onboarding_state: { currentStep: "slack-setup", finished: false },
+ },
+ },
+};
+Step4_SlackSetup.storyName = "Step 4: Slack Setup";
+
+export const Step5_WebSearch: Story = {
+ args: {
+ agent: {
+ ...baseAgent,
+ onboarding_state: { currentStep: "web-search", finished: false },
+ },
+ },
+};
+Step5_WebSearch.storyName = "Step 5: Web Search";
+
+export const Step6_Summary_Empty: Story = {
+ args: {
+ agent: {
+ ...baseAgent,
+ onboarding_state: { currentStep: "deploying", finished: false },
+ },
+ },
+};
+Step6_Summary_Empty.storyName = "Step 6: Summary (Nothing Configured)";
+
+export const Step6_Summary_AllConfigured: Story = {
+ args: {
+ agent: {
+ ...baseAgent,
+ onboarding_state: {
+ currentStep: "deploying",
+ finished: false,
+ llm: {
+ provider: "anthropic",
+ apiKey: "sk-ant-xxx",
+ },
+ webSearch: {
+ provider: "exa",
+ apiKey: "exa-xxx",
+ },
+ github: {
+ appName: "Scout",
+ appUrl: "https://github.com/apps/scout",
+ installUrl: "https://github.com/apps/scout/installations/new",
+ },
+ slack: {
+ botToken: "xoxb-xxx",
+ signingSecret: "xxx",
+ },
+ },
+ },
+ },
+};
+Step6_Summary_AllConfigured.storyName = "Step 6: Summary (All Configured)";
+
+export const Step6_Summary_Partial: Story = {
+ args: {
+ agent: {
+ ...baseAgent,
+ onboarding_state: {
+ currentStep: "deploying",
+ finished: false,
+ llm: {
+ provider: "openai",
+ apiKey: "sk-xxx",
+ },
+ github: {
+ appName: "MyApp",
+ appUrl: "https://github.com/apps/myapp",
+ installUrl: "https://github.com/apps/myapp/installations/new",
+ },
+ },
+ },
+ },
+};
+Step6_Summary_Partial.storyName = "Step 6: Summary (Partial Config)";
+
+export const Step7_Success: Story = {
+ args: {
+ agent: {
+ ...baseAgent,
+ onboarding_state: { currentStep: "success", finished: false },
+ },
+ },
+};
+Step7_Success.storyName = "Step 7: Success";
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/wizard.tsx b/internal/site/app/(app)/[organization]/~/onboarding/wizard.tsx
new file mode 100644
index 00000000..2b665c7e
--- /dev/null
+++ b/internal/site/app/(app)/[organization]/~/onboarding/wizard.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import type { OnboardingState } from "@blink.so/api";
+import { useAPIClient } from "@/lib/api-client";
+import { type AgentInfo, WizardContent } from "./components/wizard-content";
+
+export type { AgentInfo, OnboardingState };
+export type OnboardingStep = OnboardingState["currentStep"];
+
+export function OnboardingWizard({
+ organizationId,
+ organizationName,
+ agent,
+}: {
+ organizationId: string;
+ organizationName: string;
+ /** Agent with existing onboarding state (for resuming onboarding) */
+ agent?: AgentInfo;
+}) {
+ const client = useAPIClient();
+
+ return (
+
+ );
+}
diff --git a/internal/site/components/app-name-words.ts b/internal/site/components/app-name-words.ts
new file mode 100644
index 00000000..c531fb38
--- /dev/null
+++ b/internal/site/components/app-name-words.ts
@@ -0,0 +1,426 @@
+export const ADJECTIVES = [
+ "agile",
+ "amber",
+ "arctic",
+ "azure",
+ "bold",
+ "brave",
+ "bright",
+ "calm",
+ "chrome",
+ "civic",
+ "clear",
+ "clever",
+ "coral",
+ "cosmic",
+ "crisp",
+ "crystal",
+ "cyber",
+ "dark",
+ "dawn",
+ "deep",
+ "delta",
+ "dusk",
+ "eager",
+ "early",
+ "echo",
+ "elite",
+ "ember",
+ "epic",
+ "fair",
+ "fast",
+ "fern",
+ "fiery",
+ "fleet",
+ "focal",
+ "fresh",
+ "frost",
+ "gentle",
+ "gilt",
+ "glad",
+ "gold",
+ "grand",
+ "green",
+ "grey",
+ "hale",
+ "happy",
+ "hazy",
+ "high",
+ "icy",
+ "idle",
+ "iron",
+ "ivory",
+ "jade",
+ "jazzy",
+ "keen",
+ "kind",
+ "lemon",
+ "light",
+ "lime",
+ "lively",
+ "lunar",
+ "magic",
+ "merry",
+ "mild",
+ "mint",
+ "misty",
+ "mocha",
+ "navy",
+ "neat",
+ "new",
+ "next",
+ "nice",
+ "noble",
+ "north",
+ "nova",
+ "oaken",
+ "olive",
+ "onyx",
+ "opal",
+ "open",
+ "pale",
+ "peach",
+ "pearl",
+ "pine",
+ "pink",
+ "plain",
+ "plum",
+ "polar",
+ "prime",
+ "pure",
+ "quick",
+ "quiet",
+ "rapid",
+ "rare",
+ "red",
+ "rich",
+ "ripe",
+ "rose",
+ "royal",
+ "ruby",
+ "rusty",
+ "safe",
+ "sage",
+ "salty",
+ "sandy",
+ "sharp",
+ "shiny",
+ "silent",
+ "silk",
+ "silver",
+ "sleek",
+ "slim",
+ "smart",
+ "smoky",
+ "snowy",
+ "soft",
+ "solar",
+ "solid",
+ "sonic",
+ "south",
+ "space",
+ "spicy",
+ "spring",
+ "stark",
+ "steel",
+ "stone",
+ "storm",
+ "sunny",
+ "super",
+ "sweet",
+ "swift",
+ "tall",
+ "teal",
+ "tidy",
+ "tiny",
+ "topaz",
+ "true",
+ "ultra",
+ "urban",
+ "vast",
+ "velvet",
+ "vital",
+ "vivid",
+ "warm",
+ "west",
+ "white",
+ "wild",
+ "windy",
+ "wise",
+ "young",
+ "zesty",
+ "zinc",
+];
+
+export const NOUNS = [
+ "anchor",
+ "anvil",
+ "apex",
+ "arch",
+ "arrow",
+ "atlas",
+ "badge",
+ "basin",
+ "bay",
+ "beacon",
+ "beam",
+ "bear",
+ "bell",
+ "berry",
+ "birch",
+ "bird",
+ "blade",
+ "blaze",
+ "bloom",
+ "bolt",
+ "branch",
+ "breeze",
+ "brick",
+ "bridge",
+ "brook",
+ "bush",
+ "cairn",
+ "canopy",
+ "canyon",
+ "cape",
+ "cedar",
+ "chain",
+ "chapel",
+ "chart",
+ "chime",
+ "cliff",
+ "cloud",
+ "coast",
+ "comet",
+ "cone",
+ "coral",
+ "core",
+ "cosmos",
+ "cove",
+ "crane",
+ "creek",
+ "crest",
+ "crown",
+ "crystal",
+ "cube",
+ "cypress",
+ "dale",
+ "dawn",
+ "delta",
+ "den",
+ "dew",
+ "dome",
+ "door",
+ "dove",
+ "drift",
+ "drum",
+ "dune",
+ "dust",
+ "eagle",
+ "edge",
+ "elm",
+ "ember",
+ "fable",
+ "falcon",
+ "falls",
+ "feather",
+ "fern",
+ "field",
+ "finch",
+ "fjord",
+ "flame",
+ "flare",
+ "flash",
+ "flock",
+ "flora",
+ "flower",
+ "fog",
+ "ford",
+ "forge",
+ "fork",
+ "fort",
+ "fox",
+ "frost",
+ "gale",
+ "garden",
+ "gate",
+ "gem",
+ "glacier",
+ "glade",
+ "glen",
+ "globe",
+ "glow",
+ "gorge",
+ "grain",
+ "grass",
+ "grove",
+ "gulf",
+ "harbor",
+ "haven",
+ "hawk",
+ "haze",
+ "heath",
+ "hedge",
+ "helm",
+ "heron",
+ "hill",
+ "hive",
+ "hollow",
+ "hood",
+ "horn",
+ "inlet",
+ "iris",
+ "island",
+ "isle",
+ "ivy",
+ "jade",
+ "jasper",
+ "jetty",
+ "jewel",
+ "keep",
+ "kernel",
+ "key",
+ "kite",
+ "knoll",
+ "lake",
+ "lantern",
+ "lark",
+ "laurel",
+ "leaf",
+ "ledge",
+ "light",
+ "lily",
+ "link",
+ "lotus",
+ "lynx",
+ "manor",
+ "maple",
+ "marsh",
+ "meadow",
+ "mesa",
+ "mill",
+ "mist",
+ "moon",
+ "moss",
+ "moth",
+ "mount",
+ "mural",
+ "nest",
+ "nexus",
+ "north",
+ "oak",
+ "oasis",
+ "ocean",
+ "orbit",
+ "orchid",
+ "osprey",
+ "owl",
+ "palm",
+ "pass",
+ "path",
+ "peak",
+ "pearl",
+ "pebble",
+ "pier",
+ "pine",
+ "pixel",
+ "plain",
+ "planet",
+ "plaza",
+ "plum",
+ "point",
+ "pond",
+ "pool",
+ "poplar",
+ "port",
+ "prairie",
+ "prism",
+ "pulse",
+ "quartz",
+ "quest",
+ "quill",
+ "rain",
+ "range",
+ "rapids",
+ "raven",
+ "ray",
+ "reach",
+ "reef",
+ "ridge",
+ "rift",
+ "rim",
+ "ring",
+ "river",
+ "road",
+ "rock",
+ "root",
+ "rose",
+ "sage",
+ "sail",
+ "sand",
+ "scale",
+ "sea",
+ "seed",
+ "shade",
+ "shadow",
+ "shell",
+ "shield",
+ "shore",
+ "shrub",
+ "sky",
+ "slate",
+ "slope",
+ "snow",
+ "soil",
+ "south",
+ "space",
+ "spark",
+ "spire",
+ "spray",
+ "spring",
+ "spruce",
+ "spur",
+ "star",
+ "steam",
+ "stem",
+ "stone",
+ "storm",
+ "strait",
+ "stream",
+ "summit",
+ "sun",
+ "surf",
+ "swamp",
+ "swan",
+ "swift",
+ "temple",
+ "terra",
+ "thorn",
+ "thunder",
+ "tide",
+ "timber",
+ "tower",
+ "trail",
+ "tree",
+ "tulip",
+ "tundra",
+ "vale",
+ "valley",
+ "vault",
+ "veil",
+ "vertex",
+ "vine",
+ "void",
+ "wake",
+ "wave",
+ "well",
+ "west",
+ "willow",
+ "wind",
+ "wing",
+ "wood",
+ "wren",
+ "yard",
+ "zenith",
+ "zephyr",
+];
diff --git a/internal/site/components/github-setup-wizard.stories.tsx b/internal/site/components/github-setup-wizard.stories.tsx
new file mode 100644
index 00000000..7db8aa14
--- /dev/null
+++ b/internal/site/components/github-setup-wizard.stories.tsx
@@ -0,0 +1,231 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "storybook/test";
+import { type MockedClient, withMockClient } from "@/lib/api-client.mock";
+import {
+ GitHubSetupWizard,
+ type GitHubSetupWizardInitialState,
+} from "./github-setup-wizard";
+
+const TEST_AGENT_ID = "test-agent-123";
+const TEST_GITHUB_URL = "https://github.com/settings/apps/new";
+const TEST_MANIFEST = JSON.stringify({
+ name: "Test App",
+ url: "https://blink.so",
+});
+const TEST_SESSION_ID = "test-session-456";
+
+interface MockOptions {
+ creationStatus?:
+ | "pending"
+ | "app_created"
+ | "completed"
+ | "failed"
+ | "expired";
+ completeSuccess?: boolean;
+ appData?: {
+ id: number;
+ name: string;
+ html_url: string;
+ slug: string;
+ };
+}
+
+function configureMockClient(client: MockedClient, options?: MockOptions) {
+ const {
+ creationStatus = "pending",
+ completeSuccess = true,
+ appData = {
+ id: 12345,
+ name: "Test GitHub App",
+ html_url: "https://github.com/apps/test-github-app",
+ slug: "test-github-app",
+ },
+ } = options ?? {};
+
+ client.agents.setupGitHub.startCreation.mockResolvedValue({
+ manifest: TEST_MANIFEST,
+ github_url: TEST_GITHUB_URL,
+ session_id: TEST_SESSION_ID,
+ });
+
+ client.agents.setupGitHub.getCreationStatus.mockResolvedValue({
+ status: creationStatus,
+ app_data:
+ creationStatus === "completed" || creationStatus === "app_created"
+ ? appData
+ : undefined,
+ credentials:
+ creationStatus === "completed"
+ ? {
+ app_id: appData.id,
+ client_id: "Iv1.test123",
+ client_secret: "test-client-secret",
+ webhook_secret: "test-webhook-secret",
+ private_key: btoa("test-private-key"),
+ }
+ : undefined,
+ error: creationStatus === "failed" ? "Something went wrong" : undefined,
+ });
+
+ client.agents.setupGitHub.completeCreation.mockResolvedValue({
+ success: completeSuccess,
+ app_name: appData.name,
+ app_url: appData.html_url,
+ install_url: `${appData.html_url}/installations/new`,
+ });
+}
+
+const meta: Meta = {
+ title: "Components/GitHubSetupWizard",
+ component: GitHubSetupWizard,
+ parameters: {
+ layout: "centered",
+ },
+ args: {
+ agentId: TEST_AGENT_ID,
+ agentName: "Scout",
+ onComplete: fn(),
+ onBack: fn(),
+ onSkip: fn(),
+ },
+ render: (args) => (
+
+
+
+ ),
+ decorators: [withMockClient((client) => configureMockClient(client))],
+};
+
+export default meta;
+type Story = StoryObj;
+
+const withInitialState = (state: GitHubSetupWizardInitialState): Story => ({
+ args: {
+ initialState: state,
+ },
+});
+
+export const Initial: Story = withInitialState({});
+Initial.storyName = "Initial";
+
+export const WithOrganization: Story = withInitialState({
+ organization: "my-github-org",
+});
+WithOrganization.storyName = "With Organization";
+
+export const WaitingForAppCreation: Story = withInitialState({
+ hasOpenedGitHub: true,
+ sessionId: TEST_SESSION_ID,
+ manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL },
+ creationStatus: "pending",
+});
+WaitingForAppCreation.storyName = "Waiting for App Creation";
+
+export const WaitingForInstallation: Story = {
+ ...withInitialState({
+ hasOpenedGitHub: true,
+ sessionId: TEST_SESSION_ID,
+ manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL },
+ creationStatus: "app_created",
+ appData: {
+ id: 12345,
+ name: "my-org-Scout",
+ html_url: "https://github.com/apps/my-org-scout",
+ slug: "my-org-scout",
+ },
+ }),
+ decorators: [
+ withMockClient((client) =>
+ configureMockClient(client, { creationStatus: "app_created" })
+ ),
+ ],
+};
+WaitingForInstallation.storyName = "Waiting for Installation";
+
+export const Completed: Story = {
+ ...withInitialState({
+ hasOpenedGitHub: true,
+ sessionId: TEST_SESSION_ID,
+ manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL },
+ creationStatus: "completed",
+ appData: {
+ id: 12345,
+ name: "my-org-Scout",
+ html_url: "https://github.com/apps/my-org-scout",
+ slug: "my-org-scout",
+ },
+ credentials: {
+ appId: 12345,
+ clientId: "Iv1.test123",
+ clientSecret: "test-client-secret",
+ webhookSecret: "test-webhook-secret",
+ privateKey: btoa("test-private-key"),
+ },
+ }),
+ decorators: [
+ withMockClient((client) =>
+ configureMockClient(client, { creationStatus: "completed" })
+ ),
+ ],
+};
+Completed.storyName = "Completed";
+
+export const Failed: Story = {
+ ...withInitialState({
+ hasOpenedGitHub: true,
+ sessionId: TEST_SESSION_ID,
+ manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL },
+ creationStatus: "failed",
+ error: "GitHub API error: 422 Unprocessable Entity",
+ }),
+ decorators: [
+ withMockClient((client) =>
+ configureMockClient(client, { creationStatus: "failed" })
+ ),
+ ],
+};
+Failed.storyName = "Failed";
+
+export const Expired: Story = {
+ ...withInitialState({
+ hasOpenedGitHub: true,
+ sessionId: TEST_SESSION_ID,
+ manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL },
+ creationStatus: "expired",
+ }),
+ decorators: [
+ withMockClient((client) =>
+ configureMockClient(client, { creationStatus: "expired" })
+ ),
+ ],
+};
+Expired.storyName = "Expired";
+
+export const Completing: Story = {
+ ...withInitialState({
+ hasOpenedGitHub: true,
+ sessionId: TEST_SESSION_ID,
+ manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL },
+ creationStatus: "completed",
+ appData: {
+ id: 12345,
+ name: "my-org-Scout",
+ html_url: "https://github.com/apps/my-org-scout",
+ slug: "my-org-scout",
+ },
+ credentials: {
+ appId: 12345,
+ clientId: "Iv1.test123",
+ clientSecret: "test-client-secret",
+ webhookSecret: "test-webhook-secret",
+ privateKey: btoa("test-private-key"),
+ },
+ completing: true,
+ }),
+ decorators: [
+ withMockClient((client) =>
+ configureMockClient(client, { creationStatus: "completed" })
+ ),
+ ],
+};
+Completing.storyName = "Completing";
diff --git a/internal/site/components/github-setup-wizard.tsx b/internal/site/components/github-setup-wizard.tsx
new file mode 100644
index 00000000..44e4eb54
--- /dev/null
+++ b/internal/site/components/github-setup-wizard.tsx
@@ -0,0 +1,496 @@
+"use client";
+
+import { AlertCircle, ExternalLink, Loader2 } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import { OnboardingStepFooter } from "@/components/onboarding-step-footer";
+import { OnboardingStepHeader } from "@/components/onboarding-step-header";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { SetupStep } from "@/components/ui/setup-step";
+import { useAPIClient } from "@/lib/api-client";
+import { ADJECTIVES, NOUNS } from "./app-name-words";
+import { GitHubIcon } from "./icons/github";
+
+function generateRandomAppName(agentName: string): string {
+ const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
+ const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
+ const randomNumber = Math.floor(Math.random() * 900) + 100; // 100-999
+ return `${agentName}-${adjective}-${noun}-${randomNumber}`;
+}
+
+export interface GitHubSetupWizardInitialState {
+ organization?: string;
+ sessionId?: string;
+ hasOpenedGitHub?: boolean;
+ manifestData?: {
+ manifest: string;
+ github_url: string;
+ };
+ creationStatus?:
+ | "pending"
+ | "app_created"
+ | "completed"
+ | "failed"
+ | "expired";
+ appData?: {
+ id: number;
+ name: string;
+ html_url: string;
+ slug: string;
+ };
+ credentials?: GitHubAppCredentials;
+ completing?: boolean;
+ error?: string;
+}
+
+export interface GitHubAppCredentials {
+ appId: number;
+ clientId: string;
+ clientSecret: string;
+ webhookSecret: string;
+ privateKey: string; // base64-encoded PEM
+}
+
+interface GitHubSetupWizardProps {
+ agentId: string;
+ agentName: string;
+ onComplete: (result: {
+ appName: string;
+ appUrl: string;
+ installUrl: string;
+ credentials: GitHubAppCredentials;
+ }) => void;
+ onBack?: () => void;
+ onSkip?: () => void;
+ initialState?: GitHubSetupWizardInitialState;
+}
+
+export function GitHubSetupWizard({
+ agentId,
+ agentName,
+ onComplete,
+ onBack,
+ onSkip,
+ initialState,
+}: GitHubSetupWizardProps) {
+ const client = useAPIClient();
+
+ // Generate app name (user can change on GitHub)
+ const [appName] = useState(() => generateRandomAppName(agentName));
+
+ // Form state
+ const [githubOrganization, setGithubOrganization] = useState(
+ initialState?.organization ?? ""
+ );
+
+ // Session state
+ const [sessionId, setSessionId] = useState(
+ initialState?.sessionId ?? null
+ );
+ const [manifestData, setManifestData] = useState<{
+ manifest: string;
+ github_url: string;
+ } | null>(initialState?.manifestData ?? null);
+ const [hasOpenedGitHub, setHasOpenedGitHub] = useState(
+ initialState?.hasOpenedGitHub ?? false
+ );
+
+ // Creation status
+ const [creationStatus, setCreationStatus] = useState<
+ "pending" | "app_created" | "completed" | "failed" | "expired" | null
+ >(initialState?.creationStatus ?? null);
+ const [appData, setAppData] = useState<{
+ id: number;
+ name: string;
+ html_url: string;
+ slug: string;
+ } | null>(initialState?.appData ?? null);
+ const [credentials, setCredentials] = useState(
+ initialState?.credentials ?? null
+ );
+ const [error, setError] = useState(
+ initialState?.error ?? null
+ );
+
+ // Completion state
+ const [completing, setCompleting] = useState(
+ initialState?.completing ?? false
+ );
+ const [starting, setStarting] = useState(false);
+
+ const pollingRef = useRef(null);
+
+ // Determine current step
+ // Step 1 is completed only when user has clicked "Create GitHub App" (step 2 button)
+ const currentStep = useMemo(() => {
+ if (!hasOpenedGitHub) return 2;
+ if (creationStatus === "pending") return 3;
+ if (creationStatus === "app_created") return 3; // Still waiting for installation
+ if (creationStatus === "completed") return 4;
+ return 2;
+ }, [hasOpenedGitHub, creationStatus]);
+
+ // Submit manifest to GitHub via form POST (opens in new tab)
+ const submitManifestForm = useCallback(
+ (githubUrl: string, manifest: string) => {
+ const form = document.createElement("form");
+ form.method = "POST";
+ form.action = githubUrl;
+ form.target = "_blank";
+
+ const input = document.createElement("input");
+ input.type = "hidden";
+ input.name = "manifest";
+ input.value = manifest;
+
+ form.appendChild(input);
+ document.body.appendChild(form);
+ form.submit();
+ document.body.removeChild(form);
+ },
+ []
+ );
+
+ // Track the organization used for the current session
+ const [sessionOrganization, setSessionOrganization] = useState(
+ null
+ );
+
+ // Start creation flow and open GitHub in a new tab via form submission
+ const startCreation = useCallback(async () => {
+ if (starting) return;
+ setStarting(true);
+ setError(null);
+
+ const orgToUse = githubOrganization.trim() || undefined;
+
+ try {
+ const result = await client.agents.setupGitHub.startCreation(agentId, {
+ name: appName,
+ organization: orgToUse,
+ });
+ setSessionId(result.session_id);
+ setSessionOrganization(githubOrganization);
+ setManifestData({
+ manifest: result.manifest,
+ github_url: result.github_url,
+ });
+ setCreationStatus("pending");
+
+ // Submit form to GitHub - this opens in a new tab and POSTs the manifest
+ submitManifestForm(result.github_url, result.manifest);
+ setHasOpenedGitHub(true);
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to start creation"
+ );
+ setError(
+ error instanceof Error ? error.message : "Failed to start creation"
+ );
+ } finally {
+ setStarting(false);
+ }
+ }, [
+ client,
+ agentId,
+ appName,
+ githubOrganization,
+ starting,
+ submitManifestForm,
+ ]);
+
+ // Poll for creation status
+ const pollCreationStatus = useCallback(async () => {
+ if (!sessionId) return null;
+
+ try {
+ const status = await client.agents.setupGitHub.getCreationStatus(
+ agentId,
+ sessionId
+ );
+ setCreationStatus(status.status);
+ if (status.app_data) {
+ setAppData(status.app_data);
+ }
+ // Store credentials when status is completed
+ if (status.credentials) {
+ setCredentials({
+ appId: status.credentials.app_id,
+ clientId: status.credentials.client_id,
+ clientSecret: status.credentials.client_secret,
+ webhookSecret: status.credentials.webhook_secret,
+ privateKey: status.credentials.private_key,
+ });
+ }
+ if (status.error) {
+ setError(status.error);
+ }
+ return status;
+ } catch (error) {
+ // biome-ignore lint/suspicious/noConsole: useful for debugging polling failures
+ console.error("Failed to poll creation status:", error);
+ return null;
+ }
+ }, [client, agentId, sessionId]);
+
+ // Start polling when in pending or app_created state
+ useEffect(() => {
+ if (
+ (creationStatus === "pending" || creationStatus === "app_created") &&
+ sessionId
+ ) {
+ const poll = async () => {
+ const status = await pollCreationStatus();
+ if (
+ status?.status === "completed" ||
+ status?.status === "failed" ||
+ status?.status === "expired"
+ ) {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ }
+ }
+ };
+ poll();
+ pollingRef.current = setInterval(poll, 2000);
+
+ return () => {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ }
+ };
+ }
+ }, [creationStatus, sessionId, pollCreationStatus]);
+
+ // Complete setup
+ const completeSetup = useCallback(async () => {
+ if (!sessionId || !credentials || !appData) return;
+ setCompleting(true);
+
+ try {
+ // Call completeCreation to clear the server-side setup state
+ const result = await client.agents.setupGitHub.completeCreation(agentId, {
+ session_id: sessionId,
+ });
+
+ if (result.success) {
+ // Pass credentials to onComplete so the caller can save them as env vars
+ onComplete({
+ appName: appData.name,
+ appUrl: appData.html_url,
+ installUrl: `${appData.html_url}/installations/new`,
+ credentials,
+ });
+ } else {
+ toast.error("Failed to complete setup");
+ }
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to complete setup"
+ );
+ } finally {
+ setCompleting(false);
+ }
+ }, [client, agentId, sessionId, credentials, appData, onComplete]);
+
+ // Reset to try again
+ const handleRetry = () => {
+ setSessionId(null);
+ setSessionOrganization(null);
+ setManifestData(null);
+ setHasOpenedGitHub(false);
+ setCreationStatus(null);
+ setAppData(null);
+ setCredentials(null);
+ setError(null);
+ };
+
+ return (
+
+
+
+ {/* Step 1: Organization (optional) - completed when Create button is clicked */}
+
+ GitHub Organization (optional)
+
+ }
+ >
+ setGithubOrganization(e.target.value)}
+ disabled={creationStatus === "completed"}
+ />
+
+ Enter a GitHub organization name to create the app under, or leave
+ blank for a personal app.
+
+
+
+ {/* Step 2: Create GitHub App */}
+ 2}
+ headline="Create and install the GitHub App"
+ >
+
+ Click the button to open GitHub. You'll create the app and then
+ install it on your repositories.
+
+ {
+ // If org has changed since last session, start a new creation
+ const orgChanged = sessionOrganization !== githubOrganization;
+ if (manifestData && !orgChanged) {
+ // Re-open GitHub with existing manifest
+ submitManifestForm(
+ manifestData.github_url,
+ manifestData.manifest
+ );
+ } else {
+ // Start new creation flow (or restart with new org)
+ await startCreation();
+ }
+ }}
+ >
+ {starting ? (
+
+ ) : (
+
+ )}
+ {hasOpenedGitHub
+ ? "Open GitHub again"
+ : "Create & install on GitHub"}
+
+
+
+ {/* Step 3: Waiting for app creation */}
+ {creationStatus === "pending" && (
+
+
+
+ Complete the app creation on GitHub
+
+
+ )}
+
+ {/* Step 3: Waiting for installation */}
+ {creationStatus === "app_created" && (
+
+
+
+ )}
+
+ {/* Step 3/4: Error state */}
+ {(creationStatus === "failed" || creationStatus === "expired") && (
+
+
+
+ }
+ headline={
+
+ {creationStatus === "expired"
+ ? "Session expired"
+ : "Creation failed"}
+
+ }
+ >
+ {error && {error}
}
+
+ Try again
+
+
+ )}
+
+ {/* Step 3: Success */}
+ {creationStatus === "completed" && appData && (
+
+ GitHub App created and installed!
+
+ }
+ >
+
+ Click Continue below to proceed.
+
+
+
+
+ View app
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/internal/site/components/icons/github.tsx b/internal/site/components/icons/github.tsx
new file mode 100644
index 00000000..2a276e04
--- /dev/null
+++ b/internal/site/components/icons/github.tsx
@@ -0,0 +1,13 @@
+export function GitHubIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/internal/site/components/llm-api-keys-setup.tsx b/internal/site/components/llm-api-keys-setup.tsx
new file mode 100644
index 00000000..bfdf3724
--- /dev/null
+++ b/internal/site/components/llm-api-keys-setup.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+import { ExternalLink, Key } from "lucide-react";
+import { useMemo, useState } from "react";
+import {
+ type AIProvider,
+ getEnvVarKeyForProvider,
+ LLM_PROVIDERS,
+} from "@/app/(app)/[organization]/~/onboarding/llm-providers";
+import { OnboardingStepFooter } from "@/components/onboarding-step-footer";
+import { OnboardingStepHeader } from "@/components/onboarding-step-header";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { SetupStep } from "@/components/ui/setup-step";
+import { cn } from "@/lib/utils";
+
+export type { AIProvider };
+export { getEnvVarKeyForProvider };
+
+export interface LlmApiKeysResult {
+ provider: AIProvider;
+ apiKey: string;
+}
+
+export interface LlmApiKeysSetupProps {
+ initialValues?: {
+ provider?: AIProvider;
+ apiKey?: string;
+ };
+ onComplete: (result: LlmApiKeysResult) => void;
+ onBack?: () => void;
+ onSkip?: () => void;
+ completing?: boolean;
+}
+
+export function LlmApiKeysSetup({
+ initialValues,
+ onComplete,
+ onBack,
+ onSkip,
+ completing,
+}: LlmApiKeysSetupProps) {
+ const [aiProvider, setAIProvider] = useState(
+ initialValues?.provider
+ );
+ const [aiApiKey, setAIApiKey] = useState(initialValues?.apiKey || "");
+ const [hasOpenedKeyPage, setHasOpenedKeyPage] = useState(false);
+
+ const selectedProvider = LLM_PROVIDERS.find((p) => p.id === aiProvider);
+
+ const currentStep = useMemo(() => {
+ if (!aiProvider) return 1;
+ if (!hasOpenedKeyPage) return 2;
+ return 3;
+ }, [aiProvider, hasOpenedKeyPage]);
+
+ const handleComplete = () => {
+ if (aiProvider && aiApiKey.trim()) {
+ onComplete({
+ provider: aiProvider,
+ apiKey: aiApiKey.trim(),
+ });
+ }
+ };
+
+ const canComplete = aiProvider && aiApiKey.trim();
+
+ return (
+
+
+
+ {/* Step 1: Select Provider */}
+ 1}
+ headline={
+
+ Select an AI provider
+
+ }
+ >
+
+ {LLM_PROVIDERS.map((provider) => (
+
+ {
+ setAIProvider(provider.id);
+ setAIApiKey("");
+ setHasOpenedKeyPage(false);
+ }}
+ disabled={completing}
+ className="mt-0.5"
+ />
+
+
{provider.name}
+
+ {provider.description}
+
+
+
+ ))}
+
+
+
+ {/* Step 2: Create API Key */}
+
+ {
+ if (aiProvider && selectedProvider) {
+ window.open(selectedProvider.helpUrl, "_blank");
+ setHasOpenedKeyPage(true);
+ }
+ }}
+ >
+
+ {selectedProvider?.createKeyText || "Create API Key"}
+
+
+
+ {/* Step 3: Enter API Key */}
+ = 2}
+ completed={!!aiApiKey.trim()}
+ headline={
+
+ Paste your{" "}
+
+ API key
+
+
+ }
+ >
+ setAIApiKey(e.target.value)}
+ disabled={!aiProvider || completing}
+ data-1p-ignore
+ autoComplete="off"
+ />
+
+
+
+
+
+ );
+}
diff --git a/internal/site/components/onboarding-step-footer.tsx b/internal/site/components/onboarding-step-footer.tsx
new file mode 100644
index 00000000..b99593a2
--- /dev/null
+++ b/internal/site/components/onboarding-step-footer.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { ArrowLeft, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+export interface OnboardingStepFooterProps {
+ onBack?: () => void;
+ onSkip?: () => void;
+ /** Primary action. When not provided, only shows back/skip buttons */
+ onContinue?: () => void;
+ continueDisabled?: boolean;
+ disabled?: boolean;
+ continueText?: string;
+ loadingText?: string;
+ loading?: boolean;
+ /** Additional class names for the container */
+ className?: string;
+}
+
+export function OnboardingStepFooter({
+ onBack,
+ onSkip,
+ onContinue,
+ continueDisabled,
+ disabled,
+ continueText = "Continue",
+ loadingText = "Saving...",
+ loading,
+ className,
+}: OnboardingStepFooterProps) {
+ return (
+
+ {onBack && (
+
+
+ Back
+
+ )}
+
+ {onSkip && (
+
+ Skip
+
+ )}
+ {onContinue && (
+
+ {loading && }
+ {loading ? loadingText : continueText}
+
+ )}
+
+
+ );
+}
diff --git a/internal/site/components/onboarding-step-header.tsx b/internal/site/components/onboarding-step-header.tsx
new file mode 100644
index 00000000..0eb3e775
--- /dev/null
+++ b/internal/site/components/onboarding-step-header.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import type { ComponentType, ReactNode } from "react";
+import { CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+
+export interface OnboardingStepHeaderProps {
+ /** Icon component (LucideIcon or custom component like SlackIcon) */
+ icon?: ComponentType<{ className?: string }>;
+ /** Background color class for the icon circle (e.g., "bg-primary/10", "bg-[#24292f]") */
+ iconBgClassName?: string;
+ /** Icon color class (e.g., "text-primary", "text-white") */
+ iconClassName?: string;
+ /** Title text */
+ title: string;
+ /** Description text or ReactNode */
+ description: ReactNode;
+ /** Size variant - "lg" uses larger icon container (only applies to centered layout) */
+ size?: "default" | "lg";
+ /** Layout variant - "centered" for vertical centered, "inline" for horizontal with CardHeader */
+ layout?: "centered" | "inline";
+ /** Additional class names for the container */
+ className?: string;
+}
+
+export function OnboardingStepHeader({
+ icon: Icon,
+ iconBgClassName = "bg-primary/10",
+ iconClassName = "text-primary",
+ title,
+ description,
+ size = "default",
+ layout = "centered",
+ className,
+}: OnboardingStepHeaderProps) {
+ if (layout === "inline") {
+ return (
+
+
+ {Icon && (
+
+
+
+ )}
+
{title}
+
+ {description}
+
+ );
+ }
+
+ const isLarge = size === "lg";
+
+ return (
+
+ {Icon && (
+
+
+
+ )}
+
+ {title}
+
+
+ {description}
+
+
+ );
+}
diff --git a/internal/site/components/slack-icon.tsx b/internal/site/components/slack-icon.tsx
new file mode 100644
index 00000000..0beecf62
--- /dev/null
+++ b/internal/site/components/slack-icon.tsx
@@ -0,0 +1,14 @@
+export function SlackIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/internal/site/components/slack-setup-wizard.stories.tsx b/internal/site/components/slack-setup-wizard.stories.tsx
new file mode 100644
index 00000000..0d814640
--- /dev/null
+++ b/internal/site/components/slack-setup-wizard.stories.tsx
@@ -0,0 +1,415 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { useState } from "react";
+import { fn } from "storybook/test";
+import { type MockedClient, withMockClient } from "@/lib/api-client.mock";
+import {
+ SlackSetupWizard,
+ type SlackSetupWizardInitialState,
+} from "./slack-setup-wizard";
+
+const TEST_AGENT_ID = "test-agent-123";
+const TEST_WEBHOOK_URL = "https://api.blink.so/webhooks/slack/test-webhook-id";
+
+interface MockOptions {
+ validationValid?: boolean;
+ validationError?: string;
+ dmReceived?: boolean;
+ signatureFailed?: boolean;
+ completeSuccess?: boolean;
+}
+
+function configureMockClient(client: MockedClient, options?: MockOptions) {
+ const {
+ validationValid = true,
+ validationError,
+ dmReceived = false,
+ signatureFailed = false,
+ completeSuccess = true,
+ } = options ?? {};
+
+ client.agents.setupSlack.getWebhookUrl.mockResolvedValue({
+ webhook_url: TEST_WEBHOOK_URL,
+ });
+
+ client.agents.setupSlack.startVerification.mockResolvedValue({
+ webhook_url: TEST_WEBHOOK_URL,
+ });
+
+ client.agents.setupSlack.getVerificationStatus.mockResolvedValue({
+ active: true,
+ started_at: new Date().toISOString(),
+ last_event_at: dmReceived ? new Date().toISOString() : undefined,
+ dm_received: dmReceived,
+ dm_channel: dmReceived ? "D12345678" : undefined,
+ signature_failed: signatureFailed,
+ signature_failed_at: signatureFailed ? new Date().toISOString() : undefined,
+ });
+
+ client.agents.setupSlack.validateToken.mockResolvedValue({
+ valid: validationValid,
+ error: validationError,
+ });
+
+ client.agents.setupSlack.completeVerification.mockResolvedValue({
+ success: completeSuccess,
+ bot_name: "Test Bot",
+ });
+
+ client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined);
+}
+
+const meta: Meta = {
+ title: "Components/SlackSetupWizard",
+ component: SlackSetupWizard,
+ parameters: {
+ layout: "centered",
+ },
+ args: {
+ agentId: TEST_AGENT_ID,
+ agentName: "Scout",
+ onComplete: fn(),
+ onBack: fn(),
+ onSkip: fn(),
+ },
+ render: (args) => (
+
+
+
+ ),
+ decorators: [withMockClient((client) => configureMockClient(client))],
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Helper to create stories with specific initial state
+const withInitialState = (state: SlackSetupWizardInitialState): Story => ({
+ args: {
+ initialState: state,
+ },
+});
+
+export const Step1_AppName: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "",
+});
+Step1_AppName.storyName = "Step 1: App Name (empty)";
+
+export const Step1_AppNameFilled: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+});
+Step1_AppNameFilled.storyName = "Step 1: App Name (filled)";
+
+export const Step2_LoadingWebhookUrl: Story = withInitialState({
+ webhookUrl: null,
+ loadingWebhookUrl: true,
+ appName: "Scout",
+});
+Step2_LoadingWebhookUrl.storyName = "Step 2: Loading Webhook URL";
+
+export const Step2_CreateSlackApp: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: false,
+});
+Step2_CreateSlackApp.storyName = "Step 2: Create Slack App";
+
+export const Step2_SlackOpened: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+});
+Step2_SlackOpened.storyName = "Step 2: Slack Opened (can open again)";
+
+export const Step3_AppId: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "",
+});
+Step3_AppId.storyName = "Step 3: App ID (empty)";
+
+export const Step3_AppIdFilled: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+});
+Step3_AppIdFilled.storyName = "Step 3: App ID (filled)";
+
+export const Step4_SigningSecret: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+ signingSecret: "",
+});
+Step4_SigningSecret.storyName = "Step 4: Signing Secret (empty)";
+
+export const Step4_SigningSecretFilled: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+ signingSecret: "abc123secret",
+});
+Step4_SigningSecretFilled.storyName = "Step 4: Signing Secret (filled)";
+
+export const Step5_BotToken: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+ signingSecret: "abc123secret",
+ botToken: "",
+});
+Step5_BotToken.storyName = "Step 5: Bot Token (empty)";
+
+export const Step5_BotTokenFilled: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+ signingSecret: "abc123secret",
+ botToken: "xoxb-123456789-abcdefghijklmnop",
+});
+Step5_BotTokenFilled.storyName = "Step 5: Bot Token (filled, not validated)";
+
+export const Step5_BotTokenValidating: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+ signingSecret: "abc123secret",
+ botToken: "xoxb-123456789-abcdefghijklmnop",
+ validatingToken: true,
+});
+Step5_BotTokenValidating.storyName = "Step 5: Bot Token (validating)";
+
+export const Step5_BotTokenValidated: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+ signingSecret: "abc123secret",
+ botToken: "xoxb-123456789-abcdefghijklmnop",
+ tokenValidated: true,
+});
+Step5_BotTokenValidated.storyName = "Step 5: Bot Token (validated)";
+
+export const Step6_WaitingForDM: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+ signingSecret: "abc123secret",
+ botToken: "xoxb-123456789-abcdefghijklmnop",
+ tokenValidated: true,
+ verificationStarted: true,
+ dmReceived: false,
+});
+Step6_WaitingForDM.storyName = "Step 6: Waiting for DM";
+
+export const Step6_DMReceived: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+ signingSecret: "abc123secret",
+ botToken: "xoxb-123456789-abcdefghijklmnop",
+ tokenValidated: true,
+ verificationStarted: true,
+ dmReceived: true,
+});
+Step6_DMReceived.storyName = "Step 6: DM Received (ready to complete)";
+
+export const Step6_Completing: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+ signingSecret: "abc123secret",
+ botToken: "xoxb-123456789-abcdefghijklmnop",
+ tokenValidated: true,
+ verificationStarted: true,
+ dmReceived: true,
+ completing: true,
+});
+Step6_Completing.storyName = "Step 6: Completing Setup";
+
+export const Step6_SigningSecretError: Story = withInitialState({
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ hasOpenedSlack: true,
+ appId: "A0123456789",
+ signingSecret: "wrong-secret",
+ signingSecretError: true,
+ botToken: "xoxb-123456789-abcdefghijklmnop",
+ tokenValidated: true,
+ verificationStarted: true,
+ dmReceived: true,
+});
+Step6_SigningSecretError.storyName = "Step 6: Signing Secret Error";
+
+export const WithoutBackButton: Story = {
+ args: {
+ onBack: undefined,
+ initialState: {
+ webhookUrl: TEST_WEBHOOK_URL,
+ loadingWebhookUrl: false,
+ appName: "Scout",
+ },
+ },
+};
+WithoutBackButton.storyName = "Without Back Button";
+
+// Global settings that the mock client can read
+const interactiveSettings = {
+ botTokenValid: true,
+ signingSecretValid: true,
+ pollCount: 0,
+};
+
+// Interactive wrapper component with controls
+function InteractiveFlowWrapper() {
+ const [botTokenValid, setBotTokenValid] = useState(true);
+ const [signingSecretValid, setSigningSecretValid] = useState(true);
+ const [key, setKey] = useState(0);
+
+ // Update global settings when state changes
+ interactiveSettings.botTokenValid = botTokenValid;
+ interactiveSettings.signingSecretValid = signingSecretValid;
+
+ const resetWizard = () => {
+ interactiveSettings.pollCount = 0;
+ setKey((k) => k + 1);
+ };
+
+ return (
+
+
+
+
+
+
Test Controls
+
+
+
+ setBotTokenValid(e.target.checked)}
+ className="rounded"
+ />
+ Bot token validation passes
+
+
+
+ setSigningSecretValid(e.target.checked)}
+ className="rounded"
+ />
+ Signing secret verification passes
+
+
+
+
+
+
+ Reset Wizard
+
+
+
+ Toggle the checkboxes to simulate different API responses. The wizard
+ will use these settings for validation and verification.
+
+
+
+ );
+}
+
+// Configure interactive mock client with dynamic behavior
+function configureInteractiveMockClient(client: MockedClient) {
+ client.agents.setupSlack.getWebhookUrl.mockResolvedValue({
+ webhook_url: TEST_WEBHOOK_URL,
+ });
+
+ client.agents.setupSlack.startVerification.mockImplementation(() => {
+ interactiveSettings.pollCount = 0;
+ return Promise.resolve({ webhook_url: TEST_WEBHOOK_URL });
+ });
+
+ client.agents.setupSlack.getVerificationStatus.mockImplementation(() => {
+ interactiveSettings.pollCount++;
+ const dmReceived = interactiveSettings.pollCount >= 3;
+ const signatureFailed =
+ dmReceived && !interactiveSettings.signingSecretValid;
+ return Promise.resolve({
+ active: true,
+ started_at: new Date().toISOString(),
+ last_event_at:
+ interactiveSettings.pollCount > 1
+ ? new Date().toISOString()
+ : undefined,
+ dm_received: dmReceived,
+ dm_channel: dmReceived ? "D12345678" : undefined,
+ signature_failed: signatureFailed,
+ signature_failed_at: signatureFailed
+ ? new Date().toISOString()
+ : undefined,
+ });
+ });
+
+ client.agents.setupSlack.validateToken.mockImplementation(() =>
+ Promise.resolve({
+ valid: interactiveSettings.botTokenValid,
+ error: interactiveSettings.botTokenValid
+ ? undefined
+ : "Invalid bot token",
+ })
+ );
+
+ client.agents.setupSlack.completeVerification.mockResolvedValue({
+ success: true,
+ bot_name: "Scout Bot",
+ });
+
+ client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined);
+}
+
+// Interactive story that simulates the full flow with controls
+export const InteractiveFlow: Story = {
+ render: () => ,
+ decorators: [withMockClient(configureInteractiveMockClient)],
+};
+InteractiveFlow.storyName = "Interactive Flow";
diff --git a/internal/site/components/slack-setup-wizard.tsx b/internal/site/components/slack-setup-wizard.tsx
new file mode 100644
index 00000000..725c17e9
--- /dev/null
+++ b/internal/site/components/slack-setup-wizard.tsx
@@ -0,0 +1,642 @@
+"use client";
+
+import { AlertCircle, Check, ExternalLink, Loader2 } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import { OnboardingStepFooter } from "@/components/onboarding-step-footer";
+import { OnboardingStepHeader } from "@/components/onboarding-step-header";
+import { SlackIcon } from "@/components/slack-icon";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { SetupStep } from "@/components/ui/setup-step";
+import { useAPIClient } from "@/lib/api-client";
+import {
+ createAgentSlackManifest,
+ createSlackAppUrl,
+} from "@/lib/slack-manifest";
+
+export interface SlackSetupWizardInitialState {
+ webhookUrl?: string | null;
+ loadingWebhookUrl?: boolean;
+ appName?: string;
+ hasOpenedSlack?: boolean;
+ appId?: string;
+ signingSecret?: string;
+ botToken?: string;
+ validatingToken?: boolean;
+ tokenValidated?: boolean;
+ botTokenError?: boolean;
+ signingSecretError?: boolean;
+ verificationStarted?: boolean;
+ dmReceived?: boolean;
+ completing?: boolean;
+}
+
+interface SlackSetupWizardProps {
+ agentId: string;
+ agentName: string;
+ onComplete: (credentials: {
+ botToken: string;
+ signingSecret: string;
+ }) => void;
+ onBack?: () => void;
+ onSkip?: () => void;
+ /** Initial state for stories/testing - allows rendering in specific states */
+ initialState?: SlackSetupWizardInitialState;
+}
+
+export function SlackSetupWizard({
+ agentId,
+ agentName,
+ onComplete,
+ onBack,
+ onSkip,
+ initialState,
+}: SlackSetupWizardProps) {
+ const client = useAPIClient();
+
+ // Webhook URL state (fetched from backend unless provided in initialState)
+ const [webhookUrl, setWebhookUrl] = useState(
+ initialState?.webhookUrl ?? null
+ );
+ const [loadingWebhookUrl, setLoadingWebhookUrl] = useState(
+ initialState?.loadingWebhookUrl ?? initialState?.webhookUrl === undefined
+ );
+
+ // Form state
+ const [appName, setAppName] = useState(initialState?.appName ?? agentName);
+ const [hasOpenedSlack, setHasOpenedSlack] = useState(
+ initialState?.hasOpenedSlack ?? false
+ );
+ const [appId, setAppId] = useState(initialState?.appId ?? "");
+ const [signingSecret, setSigningSecret] = useState(
+ initialState?.signingSecret ?? ""
+ );
+ const [botToken, setBotToken] = useState(initialState?.botToken ?? "");
+
+ // Validation state
+ const [validatingToken, setValidatingToken] = useState(
+ initialState?.validatingToken ?? false
+ );
+ const [tokenValidated, setTokenValidated] = useState(
+ initialState?.tokenValidated ?? false
+ );
+ const [botTokenError, setBotTokenError] = useState(
+ initialState?.botTokenError ?? false
+ );
+ const [signingSecretError, setSigningSecretError] = useState(
+ initialState?.signingSecretError ?? false
+ );
+
+ // Verification state
+ const [startingVerification, setStartingVerification] = useState(false);
+ const [verificationStarted, setVerificationStarted] = useState(
+ initialState?.verificationStarted ?? false
+ );
+ const [verificationStatus, setVerificationStatus] = useState<{
+ active: boolean;
+ lastEventAt?: string;
+ dmReceived: boolean;
+ signatureFailed: boolean;
+ }>({
+ active: initialState?.verificationStarted ?? false,
+ dmReceived: initialState?.dmReceived ?? false,
+ signatureFailed: false,
+ });
+ const [completing, setCompleting] = useState(
+ initialState?.completing ?? false
+ );
+ const pollingRef = useRef(null);
+ const autoValidationTriggeredRef = useRef(false);
+
+ // Fetch webhook URL on mount (skip if provided in initialState)
+ useEffect(() => {
+ if (initialState?.webhookUrl !== undefined) return;
+
+ async function fetchWebhookUrl() {
+ try {
+ const result = await client.agents.setupSlack.getWebhookUrl(agentId);
+ setWebhookUrl(result.webhook_url);
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to load webhook URL"
+ );
+ } finally {
+ setLoadingWebhookUrl(false);
+ }
+ }
+ fetchWebhookUrl();
+ }, [client, agentId, initialState?.webhookUrl]);
+
+ // Determine which step is active (1-indexed for display)
+ const currentStep = useMemo(() => {
+ if (!appName.trim()) return 1;
+ if (!hasOpenedSlack) return 2;
+ if (!appId.trim()) return 3;
+ if (!signingSecret.trim()) return 4;
+ if (!tokenValidated) return 5;
+ return 6;
+ }, [appName, hasOpenedSlack, appId, signingSecret, tokenValidated]);
+
+ // Generate manifest URL client-side
+ const manifestUrl = useMemo(() => {
+ if (!webhookUrl) return null;
+ const manifest = createAgentSlackManifest(appName, webhookUrl);
+ return createSlackAppUrl(manifest);
+ }, [appName, webhookUrl]);
+
+ // Generate install URL from app ID
+ const installUrl = useMemo(() => {
+ if (!appId.trim()) return "";
+ return `https://api.slack.com/apps/${appId}/install-on-team`;
+ }, [appId]);
+
+ // Generate app home page URL (for signing secret)
+ const appHomeUrl = useMemo(() => {
+ if (!appId.trim()) return "";
+ return `https://api.slack.com/apps/${appId}`;
+ }, [appId]);
+
+ // Validate bot token on blur
+ const validateBotToken = useCallback(async () => {
+ if (!botToken.trim()) return;
+
+ setValidatingToken(true);
+ setBotTokenError(false);
+ try {
+ const result = await client.agents.setupSlack.validateToken(agentId, {
+ botToken,
+ });
+
+ if (result.valid) {
+ setTokenValidated(true);
+ setBotTokenError(false);
+ } else {
+ toast.error(result.error || "Invalid bot token");
+ setTokenValidated(false);
+ setBotTokenError(true);
+ }
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Validation failed");
+ setTokenValidated(false);
+ setBotTokenError(true);
+ } finally {
+ setValidatingToken(false);
+ }
+ }, [client, agentId, botToken]);
+
+ // Auto-validate when bot token reaches 8+ characters
+ useEffect(() => {
+ if (botToken.length < 8) {
+ autoValidationTriggeredRef.current = false;
+ return;
+ }
+ if (
+ !autoValidationTriggeredRef.current &&
+ !validatingToken &&
+ !tokenValidated
+ ) {
+ autoValidationTriggeredRef.current = true;
+ validateBotToken();
+ }
+ }, [botToken, validatingToken, tokenValidated, validateBotToken]);
+
+ // Start verification (called when step 6 becomes active)
+ const startVerification = useCallback(async () => {
+ if (verificationStarted || startingVerification) return;
+
+ setStartingVerification(true);
+ try {
+ await client.agents.setupSlack.startVerification(agentId, {
+ signing_secret: signingSecret,
+ bot_token: botToken,
+ });
+ setVerificationStarted(true);
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to start verification"
+ );
+ } finally {
+ setStartingVerification(false);
+ }
+ }, [
+ client,
+ agentId,
+ signingSecret,
+ botToken,
+ verificationStarted,
+ startingVerification,
+ ]);
+
+ // Poll for verification status
+ const pollVerificationStatus = useCallback(async () => {
+ try {
+ const status =
+ await client.agents.setupSlack.getVerificationStatus(agentId);
+ setVerificationStatus({
+ active: status.active,
+ lastEventAt: status.last_event_at,
+ dmReceived: status.dm_received,
+ signatureFailed: status.signature_failed,
+ });
+ return status;
+ } catch (error) {
+ // biome-ignore lint/suspicious/noConsole: useful for debugging polling failures
+ console.error("Failed to poll verification status:", error);
+ return null;
+ }
+ }, [client, agentId]);
+
+ // Start verification when step 6 is reached (not if there's a signing secret error)
+ useEffect(() => {
+ if (
+ currentStep === 6 &&
+ tokenValidated &&
+ !verificationStarted &&
+ !signingSecretError
+ ) {
+ startVerification();
+ }
+ }, [
+ currentStep,
+ tokenValidated,
+ verificationStarted,
+ signingSecretError,
+ startVerification,
+ ]);
+
+ // Start polling when verification is active
+ useEffect(() => {
+ if (verificationStarted && !verificationStatus.dmReceived) {
+ const poll = async () => {
+ const status = await pollVerificationStatus();
+ if (status?.dm_received) {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ }
+ }
+ // Detect signature failure
+ if (status?.signature_failed) {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ }
+ setSigningSecretError(true);
+ setVerificationStarted(false);
+ toast.error(
+ "Invalid signing secret. Please check and re-enter your signing secret."
+ );
+ }
+ };
+ poll();
+ pollingRef.current = setInterval(poll, 2000);
+
+ return () => {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ }
+ };
+ }
+ }, [
+ verificationStarted,
+ verificationStatus.dmReceived,
+ pollVerificationStatus,
+ ]);
+
+ // Complete setup
+ const completeSetup = async () => {
+ setCompleting(true);
+ try {
+ const result = await client.agents.setupSlack.completeVerification(
+ agentId,
+ {
+ bot_token: botToken,
+ signing_secret: signingSecret,
+ }
+ );
+
+ if (result.success) {
+ onComplete({ botToken, signingSecret });
+ } else {
+ toast.error("Failed to complete setup");
+ }
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to complete setup"
+ );
+ } finally {
+ setCompleting(false);
+ }
+ };
+
+ // Cancel verification and go back
+ const handleBack = async () => {
+ try {
+ await client.agents.setupSlack.cancelVerification(agentId);
+ } catch {
+ // Ignore errors when canceling
+ }
+ onBack?.();
+ };
+
+ return (
+
+
+
+ {/* Step 1: App Name */}
+
+ What would you like to call the agent in Slack?
+
+ }
+ >
+ setAppName(e.target.value)}
+ />
+
+
+ {/* Step 2: Create Slack App */}
+ 2}
+ headline="Create the Slack app"
+ >
+
+ {
+ if (!manifestUrl || currentStep < 2 || loadingWebhookUrl) {
+ e.preventDefault();
+ return;
+ }
+ setHasOpenedSlack(true);
+ }}
+ >
+ {loadingWebhookUrl ? (
+
+ ) : (
+
+ )}
+ {hasOpenedSlack ? "Re-create the app" : "Create the app"}
+
+
+
+
+ {/* Step 3: App ID */}
+ 3}
+ headline={
+
+ Paste the{" "}
+ = 3 ? "text-foreground" : ""}`}
+ >
+ App ID
+ {" "}
+ from the app home page
+
+ }
+ >
+ setAppId(e.target.value)}
+ disabled={currentStep < 3}
+ data-1p-ignore
+ autoComplete="off"
+ />
+
+
+ {/* Step 4: Signing Secret */}
+ 4}
+ indicator={
+ signingSecretError ? (
+
+ ) : undefined
+ }
+ headline={
+
+ Paste the{" "}
+ = 4 ? "text-foreground" : ""}`}
+ >
+ Signing Secret
+ {" "}
+ from{" "}
+ {appId && signingSecretError ? (
+
+ the same page
+
+
+ ) : (
+ "the same page"
+ )}
+
+ }
+ >
+ {
+ setSigningSecret(e.target.value);
+ if (signingSecretError) {
+ // Clear error and reset verification when user fixes signing secret
+ setSigningSecretError(false);
+ setVerificationStarted(false);
+ setVerificationStatus({
+ active: false,
+ dmReceived: false,
+ signatureFailed: false,
+ });
+ }
+ }}
+ disabled={currentStep < 4}
+ data-1p-ignore
+ autoComplete="off"
+ className={
+ signingSecretError
+ ? "border-yellow-500 focus-visible:ring-yellow-500"
+ : ""
+ }
+ />
+ {signingSecretError && (
+
+ Signing secret verification failed. Did you enter it correctly?
+
+ )}
+
+
+ {/* Step 5: Bot Token */}
+
+
+
+ ) : undefined
+ }
+ headline={
+
+ {appId && currentStep >= 5 ? (
+
+ Install the app
+
+
+ ) : (
+ = 5 ? "text-blue-500" : ""}`}
+ >
+ Install the app
+
+
+ )}{" "}
+ and paste the{" "}
+ = 5 ? "text-foreground" : ""}`}
+ >
+ Bot Token
+
+
+ }
+ >
+
+
{
+ setBotToken(e.target.value);
+ setTokenValidated(false);
+ setBotTokenError(false);
+ }}
+ onBlur={validateBotToken}
+ disabled={currentStep < 5}
+ data-1p-ignore
+ autoComplete="off"
+ className={`${tokenValidated ? "pr-10" : ""} ${botTokenError ? "border-yellow-500 focus-visible:ring-yellow-500" : ""}`}
+ />
+ {validatingToken && (
+
+
+
+ )}
+ {tokenValidated && !validatingToken && (
+
+
+
+ )}
+
+ {botTokenError && (
+
+ Bot token validation failed. Did you enter it correctly?
+
+ )}
+
+
+ {/* Step 6: DM Verification */}
+
+ {currentStep === 6 && (
+
+ {/* DM Status */}
+
+ {verificationStatus.dmReceived && !signingSecretError ? (
+
+ ) : (
+
+ )}
+
+ {verificationStatus.dmReceived && !signingSecretError
+ ? "Message received!"
+ : signingSecretError
+ ? "Message received..."
+ : "Waiting for message..."}
+
+
+
+ {/* Subtitle */}
+
+ {signingSecretError
+ ? `There was a problem with the signing secret. Please fix it and DM ${appName} again.`
+ : `Find "${appName}" in the Slack search bar and DM it.`}
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/internal/site/components/ui/setup-step.tsx b/internal/site/components/ui/setup-step.tsx
new file mode 100644
index 00000000..7b48fe64
--- /dev/null
+++ b/internal/site/components/ui/setup-step.tsx
@@ -0,0 +1,43 @@
+import type { ReactNode } from "react";
+import { StepNumber } from "./step-number";
+
+interface SetupStepProps {
+ num: number;
+ active: boolean;
+ completed: boolean;
+ headline: ReactNode;
+ children: ReactNode;
+ /** Optional custom indicator to replace the StepNumber (e.g., for error states) */
+ indicator?: ReactNode;
+}
+
+export function SetupStep({
+ num,
+ active,
+ completed,
+ headline,
+ children,
+ indicator,
+}: SetupStepProps) {
+ return (
+
+ {indicator ?? (
+
+ )}
+
+ {typeof headline === "string" ? (
+
+ {headline}
+
+ ) : (
+ headline
+ )}
+ {children}
+
+
+ );
+}
diff --git a/internal/site/components/ui/step-number.tsx b/internal/site/components/ui/step-number.tsx
new file mode 100644
index 00000000..f21ba4f9
--- /dev/null
+++ b/internal/site/components/ui/step-number.tsx
@@ -0,0 +1,23 @@
+import { Check } from "lucide-react";
+
+interface StepNumberProps {
+ num: number;
+ active: boolean;
+ completed: boolean;
+}
+
+export function StepNumber({ num, active, completed }: StepNumberProps) {
+ return (
+
+ {completed ? : num}
+
+ );
+}
diff --git a/internal/site/components/web-search-setup.tsx b/internal/site/components/web-search-setup.tsx
new file mode 100644
index 00000000..6576bd0c
--- /dev/null
+++ b/internal/site/components/web-search-setup.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import { ExternalLink, Info, Search } from "lucide-react";
+import { useState } from "react";
+import { OnboardingStepFooter } from "@/components/onboarding-step-footer";
+import { OnboardingStepHeader } from "@/components/onboarding-step-header";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { SetupStep } from "@/components/ui/setup-step";
+
+export interface WebSearchResult {
+ exaApiKey: string;
+}
+
+export interface WebSearchSetupProps {
+ initialValue?: string;
+ onComplete: (result: WebSearchResult) => void;
+ onBack?: () => void;
+ onSkip?: () => void;
+ completing?: boolean;
+}
+
+export const EXA_ENV_VAR_KEY = "EXA_API_KEY";
+
+export function WebSearchSetup({
+ initialValue,
+ onComplete,
+ onBack,
+ onSkip,
+ completing,
+}: WebSearchSetupProps) {
+ const [exaApiKey, setExaApiKey] = useState(initialValue || "");
+ const [hasOpenedKeyPage, setHasOpenedKeyPage] = useState(false);
+
+ const handleComplete = () => {
+ if (exaApiKey.trim()) {
+ onComplete({ exaApiKey: exaApiKey.trim() });
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Exa is a web search provider for AI agents.{" "}
+
+ Learn more
+
+
+
+
+ {/* Step 1: Create API Key */}
+
+ {
+ window.open("https://dashboard.exa.ai/api-keys", "_blank");
+ setHasOpenedKeyPage(true);
+ }}
+ >
+
+ Create Exa API Key
+
+
+
+ {/* Step 2: Enter API Key */}
+
+ Paste your API key
+
+ }
+ >
+ setExaApiKey(e.target.value)}
+ disabled={completing}
+ data-1p-ignore
+ autoComplete="off"
+ />
+
+
+
+
+
+ );
+}
diff --git a/internal/site/lib/api-client.mock.tsx b/internal/site/lib/api-client.mock.tsx
new file mode 100644
index 00000000..dff3fd0d
--- /dev/null
+++ b/internal/site/lib/api-client.mock.tsx
@@ -0,0 +1,107 @@
+import Client from "@blink.so/api";
+import type { StoryFn } from "@storybook/react";
+import { fn } from "storybook/test";
+
+// Type-safe mock function that preserves the original function's types
+// biome-ignore lint/suspicious/noExplicitAny: generic function type
+type TypedMockFn any> = T & {
+ mockResolvedValue: (value: Awaited>) => TypedMockFn;
+ mockRejectedValue: (value: unknown) => TypedMockFn;
+ mockImplementation: (
+ impl: (...args: Parameters) => ReturnType
+ ) => TypedMockFn;
+ mockReturnValue: (value: ReturnType) => TypedMockFn;
+ mockClear: () => TypedMockFn;
+ mockReset: () => TypedMockFn;
+};
+
+// Type that converts all methods in an object to typed mocks
+type MockedMethods = {
+ // biome-ignore lint/suspicious/noExplicitAny: recursive type needs any
+ [K in keyof T]: T[K] extends (...args: any[]) => any
+ ? TypedMockFn
+ : T[K] extends object
+ ? MockedMethods
+ : T[K];
+};
+
+// Mocked Client type where all methods are type-safe Storybook mocks
+export type MockedClient = MockedMethods;
+
+/**
+ * Recursively replace all methods with mocks that reject by default.
+ * Stories must explicitly mock methods they use with .mockResolvedValue().
+ */
+function mockAllMethods(
+ obj: object,
+ path = "",
+ visited = new WeakSet()
+): void {
+ if (visited.has(obj)) return;
+ visited.add(obj);
+
+ for (const key of Object.keys(obj)) {
+ // biome-ignore lint/suspicious/noExplicitAny: easier that way
+ const value = (obj as any)[key];
+ const newPath = path ? `${path}.${key}` : key;
+
+ if (value && typeof value === "object" && value.constructor !== Object) {
+ // Nested class instance - recurse into it and also mock its prototype methods
+ mockAllMethods(value, newPath, visited);
+ for (const method of Object.getOwnPropertyNames(
+ Object.getPrototypeOf(value)
+ )) {
+ if (method !== "constructor" && typeof value[method] === "function") {
+ value[method] = fn().mockRejectedValue(
+ new Error(`${newPath}.${method} not mocked`)
+ );
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Create a mock API client where all methods reject by default.
+ * Stories must explicitly mock methods they use with .mockResolvedValue().
+ */
+export function createMockClient(): MockedClient {
+ const client = new Client({ baseURL: "http://mock" });
+ mockAllMethods(client);
+ return client as unknown as MockedClient;
+}
+
+// Current mock client for the active story
+let currentMockClient: MockedClient | null = null;
+
+/**
+ * Storybook decorator that creates a fresh mock client for each story.
+ * Pass a configure function to set up mock responses.
+ *
+ * @example
+ * export const MyStory: Story = {
+ * decorators: [
+ * withMockClient((client) => {
+ * client.agents.setupSlack.getWebhookUrl.mockResolvedValue({ webhook_url: "..." });
+ * }),
+ * ],
+ * };
+ */
+export function withMockClient(configure?: (client: MockedClient) => void) {
+ return (Story: StoryFn) => {
+ // Create fresh mock client for this story
+ currentMockClient = createMockClient();
+ if (configure) {
+ configure(currentMockClient);
+ }
+ // @ts-expect-error StoryFn typing
+ return ;
+ };
+}
+
+export const useAPIClient = fn(() => {
+ if (!currentMockClient) {
+ currentMockClient = createMockClient();
+ }
+ return currentMockClient as unknown as Client;
+});
diff --git a/internal/site/lib/api-client.ts b/internal/site/lib/api-client.ts
index 91f22365..a1f1c90f 100644
--- a/internal/site/lib/api-client.ts
+++ b/internal/site/lib/api-client.ts
@@ -7,7 +7,8 @@ export function useAPIClient() {
return useMemo(
() =>
new Client({
- baseURL: typeof window !== "undefined" ? window.location.origin : "",
+ baseURL:
+ typeof window !== "undefined" ? window.location.origin : undefined,
}),
[]
);
diff --git a/internal/site/lib/slack-manifest.ts b/internal/site/lib/slack-manifest.ts
new file mode 100644
index 00000000..b81e4fa7
--- /dev/null
+++ b/internal/site/lib/slack-manifest.ts
@@ -0,0 +1,107 @@
+/**
+ * Creates a Slack manifest URL for app creation.
+ */
+
+export interface SlackManifest {
+ display_information: {
+ name: string;
+ description?: string;
+ };
+ features?: {
+ bot_user?: {
+ display_name: string;
+ always_online?: boolean;
+ };
+ app_home?: {
+ home_tab_enabled?: boolean;
+ messages_tab_enabled?: boolean;
+ messages_tab_read_only_enabled?: boolean;
+ };
+ };
+ oauth_config: {
+ scopes: {
+ bot?: string[];
+ user?: string[];
+ };
+ };
+ settings?: {
+ event_subscriptions?: {
+ request_url: string;
+ bot_events?: string[];
+ };
+ interactivity?: {
+ is_enabled: boolean;
+ request_url: string;
+ };
+ org_deploy_enabled?: boolean;
+ socket_mode_enabled?: boolean;
+ token_rotation_enabled?: boolean;
+ };
+}
+
+/**
+ * Creates a URL to initialize Slack App creation with a manifest.
+ * @param manifest The Slack App manifest configuration
+ * @returns URL to create the Slack app with the provided manifest
+ */
+export function createSlackAppUrl(manifest: SlackManifest): string {
+ const manifestJson = encodeURIComponent(JSON.stringify(manifest));
+ return `https://api.slack.com/apps?new_app=1&manifest_json=${manifestJson}`;
+}
+
+/**
+ * Creates a default Slack manifest for a Blink agent.
+ * @param appName The display name for the Slack app
+ * @param webhookUrl The webhook URL for event subscriptions
+ * @returns A Slack manifest configured for the agent
+ */
+export function createAgentSlackManifest(
+ appName: string,
+ webhookUrl: string
+): SlackManifest {
+ return {
+ display_information: {
+ name: appName,
+ description: `Chat with ${appName}`,
+ },
+ features: {
+ bot_user: {
+ display_name: appName,
+ always_online: true,
+ },
+ app_home: {
+ home_tab_enabled: false,
+ messages_tab_enabled: true,
+ messages_tab_read_only_enabled: false,
+ },
+ },
+ oauth_config: {
+ scopes: {
+ bot: [
+ "assistant:write",
+ "chat:write",
+ "im:history",
+ "im:read",
+ "im:write",
+ ],
+ },
+ },
+ settings: {
+ event_subscriptions: {
+ request_url: webhookUrl,
+ bot_events: [
+ "assistant_thread_context_changed",
+ "assistant_thread_started",
+ "message.im",
+ ],
+ },
+ interactivity: {
+ is_enabled: true,
+ request_url: webhookUrl,
+ },
+ org_deploy_enabled: false,
+ socket_mode_enabled: false,
+ token_rotation_enabled: false,
+ },
+ };
+}
diff --git a/internal/site/middleware.ts b/internal/site/middleware.ts
index 84e59f68..6fec3319 100644
--- a/internal/site/middleware.ts
+++ b/internal/site/middleware.ts
@@ -79,12 +79,17 @@ export async function middleware(request: NextRequest) {
currentCookie?.value ||
(response ? response.cookies.get(SESSION_COOKIE_NAME)?.value : undefined);
+ const authSecret = process.env.AUTH_SECRET;
+ if (!authSecret) {
+ throw new Error("AUTH_SECRET environment variable is not set");
+ }
+
let token = null;
if (tokenValue) {
try {
token = await decode({
token: tokenValue,
- secret: process.env.AUTH_SECRET!,
+ secret: authSecret,
salt: SESSION_COOKIE_NAME,
});
} catch {
diff --git a/internal/worker/src/new-api.ts b/internal/worker/src/new-api.ts
index 1f5e35f9..dba2fa9a 100644
--- a/internal/worker/src/new-api.ts
+++ b/internal/worker/src/new-api.ts
@@ -47,6 +47,7 @@ export default function handleNewAPI(
GOOGLE_CLIENT_SECRET: env.GOOGLE_CLIENT_SECRET,
apiBaseURL,
+ accessUrl: apiBaseURL,
matchRequestHost: (host) => {
// These two are for backwards compatibility.
let exec = devRequestHostRegex.exec(host);
@@ -425,6 +426,9 @@ export default function handleNewAPI(
NODE_ENV: env.NODE_ENV,
AI_GATEWAY_API_KEY: env.AI_GATEWAY_API_KEY,
TOOLS_EXA_API_KEY: env.EXA_API_KEY,
+ serverVersion: "worker",
+ ONBOARDING_AGENT_BUNDLE_URL:
+ "https://artifacts.blink.host/starter-agent/bundle.tar.gz",
},
ctx
);
diff --git a/packages/server/package.json b/packages/server/package.json
index adb6910a..b58b5c9c 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -27,12 +27,12 @@
"dev": "bun src/cli.ts --dev"
},
"devDependencies": {
- "blink": "workspace:*",
- "@blink.so/api": "workspace:*",
"@blink-sdk/tunnel": "workspace:*",
+ "@blink.so/api": "workspace:*",
"@types/node": "^22.10.2",
"@types/pg": "^8.11.10",
"@types/ws": "^8.5.13",
+ "blink": "workspace:*",
"boxen": "^8.0.1",
"chalk": "^5.4.1",
"commander": "^12.1.0",
diff --git a/packages/server/src/cli.ts b/packages/server/src/cli.ts
index a375b10b..acb93f1c 100644
--- a/packages/server/src/cli.ts
+++ b/packages/server/src/cli.ts
@@ -88,6 +88,7 @@ async function runServer(options: { port: string; dev?: boolean | string }) {
authSecret,
baseUrl,
devProxy,
+ accessUrl,
});
const box = boxen(
diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
index fafe1bf0..75856bd3 100644
--- a/packages/server/src/server.ts
+++ b/packages/server/src/server.ts
@@ -1,6 +1,7 @@
import api from "@blink.so/api/server";
import connectToPostgres from "@blink.so/database/postgres";
import Querier from "@blink.so/database/querier";
+import pkg from "../package.json" with { type: "json" };
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { existsSync } from "fs";
import { readFile } from "fs/promises";
@@ -24,12 +25,14 @@ interface ServerOptions {
authSecret: string;
baseUrl: string;
devProxy?: string; // e.g. "localhost:3000"
+ accessUrl: string;
}
// Files are now stored in the database instead of in-memory
export async function startServer(options: ServerOptions) {
- const { port, postgresUrl, authSecret, baseUrl, devProxy } = options;
+ const { port, postgresUrl, authSecret, baseUrl, accessUrl, devProxy } =
+ options;
const db = await connectToPostgres(postgresUrl);
const querier = new Querier(db);
@@ -127,6 +130,9 @@ export async function startServer(options: ServerOptions) {
{
AUTH_SECRET: authSecret,
NODE_ENV: "development",
+ serverVersion: pkg.version,
+ ONBOARDING_AGENT_BUNDLE_URL:
+ "https://artifacts.blink.host/starter-agent/bundle.tar.gz",
agentStore: (deploymentTargetID) => {
return {
delete: async (key) => {
@@ -181,6 +187,7 @@ export async function startServer(options: ServerOptions) {
return querier;
},
apiBaseURL: url,
+ accessUrl: new URL(accessUrl),
auth: {
handleWebSocketTokenRequest: async (id, request) => {
// WebSocket upgrades are handled in the 'upgrade' event