Build your first agent
This guide wires Zupflow into a product, end to end. A new customer signs up, connects their Linear account, and an agent plans their week with the tools you allow it, while your backend hears about every step.
- 1WorkspaceFor the new customer
- 2ConnectThey link Linear
- 3AgentAnd the tools it may use
- 4WebhookHear about every run
- 5RunAnd read its result
Set up the client
Section titled “Set up the client”Every call comes from your backend, with an API key of one environment. Keep the key out of the browser.
import { createClient, createHttpTransport } from "@zupflow/sdk";
export const zupflow = createClient( createHttpTransport({ baseUrl: process.env.ZUPFLOW_URL!, // your Zupflow API URL getToken: async () => process.env.ZUPFLOW_API_KEY!, // zf_test_… for Sandbox }),);The SDK returns the response’s data directly, throws a typed error on failure, and sets an Idempotency-Key on every POST, so a retried call never acts twice.
-
Create a workspace when a customer signs up. One per customer of yours, with your own ID for them as
external_id. It comes back on every webhook, so you never need to store Zupflow’s IDs to route events.const workspace = await zupflow.workspaces.create(organizationId, {name: "Acme Corp",slug: "acme",environment: "sandbox",external_id: customer.id,}); -
Send the customer through hosted Connect. Create a session for the app, then redirect them to its URL (or open it in a popup). Zupflow runs the sign-in, seals the credentials into an installation, and lists the app’s tools in the background.
const session = await zupflow.connectSessions.create(workspace.id, {app_id: linearAppId,intent: "create",label: "Linear",installation_id: null,return_url: "https://app.example.com/integrations",});redirect(session.url);When they come back, read the session to find the installation:
const finished = await zupflow.connectSessions.get(workspace.id, session.id);if (finished.status !== "completed") throw new Error(`Connect ${finished.status}`);const installationId = finished.installation_id!;The Connect guide covers popups, reconnecting and the messages the page sends.
-
Create an agent and grant it tools. An agent is instructions and a model. A grant gives it named tools of one installation; any tool you list in
approval_requiredwaits for a person before it runs.const agent = await zupflow.agents.create(workspace.id, {name: "Planner",description: "Plans the week from the team's Linear issues.",instructions: "You help the team plan their week. Be brief.",model_id: null, // the environment's default model});const { items: tools } = await zupflow.installations.tools(workspace.id, installationId);await zupflow.grants.create(workspace.id, agent.id, {installation_id: installationId,tools: tools.map((tool) => tool.name),approval_required: ["create_issue", "update_issue"],}); -
Register a webhook endpoint. Subscribe to the run events you act on. The signing secret is in this response only, so store it now.
const { endpoint, signing_secret } = await zupflow.webhooks.create(organizationId, {environment: "sandbox",url: "https://app.example.com/zupflow/webhooks",description: "Runs",events: ["run.completed", "run.failed", "run.waiting_for_approval"],});await secrets.put("ZUPFLOW_WEBHOOK_SECRET", signing_secret);Then handle deliveries. Verify the raw body before trusting anything in it:
routes/zupflow-webhooks.ts import { verifyWebhook } from "@zupflow/sdk";export async function POST(request: Request) {const raw = await request.text();const verified = await verifyWebhook(raw,request.headers.get("Zupflow-Signature"),process.env.ZUPFLOW_WEBHOOK_SECRET!,);if (!verified.known) return new Response(null, { status: 204 }); // a newer event typeconst { event } = verified;switch (event.type) {case "run.waiting_for_approval":await askPerson(event.workspace.external_id, event.data.id);break;case "run.completed":case "run.failed":await jobs.enqueue("read-run", { workspaceId: event.workspace.id, runId: event.data.id });break;}return new Response(null, { status: 204 });}Answer quickly with a
2xxand do the work in your own background job: a delivery that takes over 10 seconds is retried. -
Start a run. Give the agent the person’s input. The run starts right away and moves on its own; the webhook tells you when it’s done.
const run = await zupflow.runs.create(workspace.id, agent.id, {input: "What's due this week? Move anything overdue to next sprint.",});When
run.completedarrives, read the result:const done = await zupflow.runs.get(workspaceId, runId);console.log(done.output);And if the agent wanted to update an issue,
run.waiting_for_approvalcame first: your product asks the person, and your backend sends the decision. That’s the Ask before acting guide.
What happened
Section titled “What happened”sequenceDiagram participant B as Your backend participant Z as Zupflow participant M as Model participant L as Linear (MCP) B->>Z: runs.create Z->>M: turn 1, with the granted tools M-->>Z: call list_issues Z->>L: tools/call list_issues L-->>Z: issues Z->>M: turn 2 M-->>Z: call update_issue Z-->>B: run.waiting_for_approval B->>Z: runs.resolveApproval (approved) Z->>L: tools/call update_issue Z->>M: turn 3 M-->>Z: final answer Z-->>B: run.completedEvery step was committed before the next one started, and every tool call was decided by the policy before it was sent. See How it works for how.
- Connect an account: popups, reconnecting, and Connect’s webhooks.
- Ask before acting: show pending calls and send decisions.
- Webhooks: signatures, retries and every event type.