PorchDashboard →
Docs

Hosted status pages for your agent.

Your agent runs in the background. Your customer watches a live URL. Wire it in once; never build a custom progress UI again.

Install

One package, one environment variable. The SDK reads PORCH_API_KEY from the environment by default — no client construction required for the common path.

npm install @porch/sdk

Then put your API key in the environment. Grab it from the dashboard.

PORCH_API_KEY=porch_...

The TypeScript SDK ships ESM-first; CommonJS consumers can require("@porch/sdk") via the dual export. Node 18+ recommended.

Quickstart

Create a track with the steps you know upfront. Send the URL to your customer. Drive the steps as your agent works. That’s the whole product — everything below is shaping the customer’s reading experience.

import { createTrack } from "@porch/sdk";

const track = await createTrack({
  title: "Importing your Salesforce contacts",
  steps: [
    "Connecting to Salesforce",
    "Mapping custom fields",
    "Importing contacts",
    "Verifying",
  ],
});

// Send track.url to your customer (email, in-app, SMS, anywhere).

await track.startStep(0);
await track.action("Authenticating with Salesforce OAuth");
await track.completeStep(0, { detail: "Authenticated as john@acme.com" });

// ... and so on ...

await track.complete({
  summary: "Imported 12,043 contacts. Skipped 32 duplicates.",
  cta: { label: "Open your contacts", url: "https://yourapp.com/contacts" },
});

The narration vocabulary

The status page has two surfaces: the stepper on the left (high-level checklist) and the Live Activity feed on the right (the agent thinking out loud). Pick the right method for the right surface.

track.think(msg)
Live Activity · THOUGHT
Reasoning, decisions, strategy. The why.
track.action(msg)
Live Activity · ACTION
Tool calls, API requests. The what.
track.result(msg)
Live Activity · neutral
Counts, partial outcomes. The what came back.
track.progress({ current, total })
Stepper / metrics card
Concrete units: 2,148 / 3,214 records.
track.log(msg)
Step's expandable logs
Debug detail. Not in the live feed.

Lead with rationale, not chatter. Use numbers when you have them. One beat per call. If it’s “retrying with 500ms backoff,” that’s a track.log(), not a track.result().

When you don't know the steps

For dynamic agents whose next move isn’t knowable upfront — research, multi-step LLM workflows — omit steps. The page renders as a single-column narrative.

const track = await createTrack({ title: "Researching your competitors" });

await track.think("Starting with the top 5 by market share");
await track.action("Searching for primary competitors");
await track.result("Found 5 relevant companies: Acme, Beta, ...");
await track.action("Gathering pricing data for each");
// ...

await track.complete({ summary: "Researched 5 competitors. Brief ready." });

Narration mode is right for any workflow where pre-declaring the steps would mean lying. A research agent that might branch from 5 sources to 50 mid-run, an LLM tool-use loop with indeterminate length, an investigation that follows the evidence — these belong in narration mode. The customer reads the agent’s thinking instead of a checklist.

Failure

Always tell the customer when something goes wrong. The page renders the reason, plus a recovery line if you set userAction. Never let a track silently stall — a forever-spinning page erodes trust faster than an honest failure.

try {
  // ... agent work ...
} catch (err) {
  await track.fail({
    step: currentStepIndex,
    reason: "Couldn't reach Salesforce — their API is down",
    retryable: true,
    userAction: "wait_and_retry", // or "contact_support"
  });
  throw err;
}

userAction controls the recovery affordance the customer sees: "wait_and_retry"renders a soft “we’ll try again automatically” line; "contact_support" renders a mailto anchor; null shows the failure quietly with no call to action. Pick the one that matches what the customer should actually do.

Across process boundaries

Create the track in one process, drive it from another. Pass the id; never serialize the handle. This is the standard pattern for any background-job architecture (Inngest, Trigger.dev, BullMQ, Celery, RQ, custom queues).

// Process A — create, send URL, enqueue work.
const track = await createTrack({ title: "...", steps: [...] });
await sendEmail(customer.email, { link: track.url });
await queue.enqueue("importJob", { trackId: track.id });

// Process B (worker) — reconstruct.
import { getTrack } from "@porch/sdk";
const track = await getTrack(job.data.trackId);
await track.startStep(0);

The id is a short opaque string; safe in a queue payload, a URL, a database row. The track URL is constructed from a different (longer) slug so the id can’t be reverse-engineered from a public link.

Timing model

Every SDK call is one HTTP POST. Nothing is buffered, batched, or coalesced — when await track.action(…) resolves, the event is on-screen for your customer.

Transport failures (DNS, connection drop) are retried up to 3 times with geometric backoff plus jitter. HTTP errors (4xx / 5xx) are notretried — retrying a POST the server already accepted would silently duplicate the event in the customer’s feed.

For agents emitting sub-second beats, prefer track.progress() (idempotent counter) or track.log() (debug-only, not in the live feed) over per-record narration. The Live Activity feed is a reading surface, not a log dump.

// Good: one update per chunk, with concrete units.
for (let i = 0; i < records.length; i += 100) {
  await processChunk(records.slice(i, i + 100));
  await track.progress({
    current: Math.min(i + 100, records.length),
    total: records.length,
    unit: "records",
  });
}

// Bad: per-record action() noise drowns the customer.
for (const record of records) {
  await track.action(`Processing record ${record.id}`);
  await processRecord(record);
}

For your coding agent

Two pieces. The skill teaches Claude Code or Cursor what Porch is and how to integrate it correctly — when to suggest it, the two track styles, the narration vocab, the common mistakes. The MCP server lets the agent act on Porch directly — create tracks, drive them through their lifecycle, read state — without leaving your editor. Install both for the smoothest experience.

1. Drop in the skill

mkdir -p ~/.claude/skills/porch && \
  curl -fsSL https://porch.so/skill.md -o ~/.claude/skills/porch/SKILL.md

Picked up on the next prompt — no restart. Cursor reads from the same directory.

2. Wire up the MCP server

For Claude Code:

claude mcp add --transport http porch https://porch.so/mcp

For Cursor (or any MCP client that uses JSON config):

{
  "mcpServers": {
    "porch": {
      "url": "https://porch.so/mcp",
      "headers": { "Authorization": "Bearer ${PORCH_API_KEY}" }
    }
  }
}

There’s also an LLM-readable integration guide at porch.so/llms.txt — point any coding agent at it before asking for an integration. The guide covers both TypeScript and Python in every example.

Going deeper

  • /llms.txt — the full LLM-readable integration guide. Five patterns, two languages, plus the when-to-suggest heuristics for coding agents.
  • github.com/porch-so/sdk — TypeScript SDK source and the full method reference.
  • github.com/porch-so/sdk-python — Python SDK source. Sync and async surfaces, identical method names.
  • Dashboard — API keys, billing, recent tracks.