The orchestration runner

The runner is the bridge between a non-LLM UI and Prscnt's orchestration. It's a small webhook server that hosts an LLM agent with the Prscnt MCP (and your connectors) attached. Your UI sends it a task; it runs the agent loop and returns the result. The complete template is below — copy the files into your own project. There's nothing to clone.

Why it exists

Prscnt's tools are only reachable by an LLM-driven MCP client. Your Softr button or Airtable automation isn't one. The runner is — it holds the LLM and the MCP connection, so your UI just makes a plain HTTP call. You host it (your compute, your keys); Prscnt provides the brain.

Architecture

Your UI            "Quote this deal", "Vet brand", "Draft outreach"
   |  POST /run { task, context }
Runner             LLM agent loop + Prscnt MCP + your own MCPs
   |  reads context, calls Prscnt + your tools, composes
Your data          results written back through your connector

The complete template

Create a new folder and add these five files. That's the whole runner — copy them as-is, then run it.

package.json

{
  "name": "prscnt-orchestration-runner",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "start": "tsx src/server.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@anthropic-ai/claude-agent-sdk": "^0.3.159"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "tsx": "^4.20.0",
    "typescript": "^5.6.0"
  },
  "engines": { "node": ">=20.0.0" }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

.env.example

# Your Anthropic API key (the LLM that runs the orchestration loop).
ANTHROPIC_API_KEY=sk-ant-...

# Your Prscnt API key. Connect at https://mcp.prscnt.com/connect, then ask
# Prscnt to mint a key for headless use. Your workspace is resolved from this
# key — you never pass a workspace id.
PRSCNT_API_KEY=prscnt_live_...

# Optional:
# PRSCNT_AGENT_MODEL=claude-sonnet-4-6     # model the agent runs on
# PORT=8787                                # webhook port
# RUNNER_TOKEN=change-me                   # require x-runner-token on /run (do this in prod)
# RUNNER_TIMEOUT_MS=300000                 # abort a run after N ms
# ALLOW_OUTBOUND=                          # set to 1 to disable the human-approval gate

src/agent.ts

import { query, type Options, type McpServerConfig, type CanUseTool } from "@anthropic-ai/claude-agent-sdk";

const PRSCNT_MCP_URL = process.env.PRSCNT_MCP_URL ?? "https://mcp.prscnt.com/v1/mcp";
const MODEL = process.env.PRSCNT_AGENT_MODEL ?? "claude-sonnet-4-6";
const ALLOW_OUTBOUND = process.env.ALLOW_OUTBOUND === "1";

function requireEnv(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`Missing required env var ${name} (see .env.example)`);
  return v;
}

/** Fail fast at startup instead of on the first request. Call before listen(). */
export function assertEnv(): void {
  for (const name of ["ANTHROPIC_API_KEY", "PRSCNT_API_KEY"]) requireEnv(name);
}

/** The MCP servers the agent composes. Prscnt is always here; add your own below. */
function buildMcpServers(): Record<string, McpServerConfig> {
  const servers: Record<string, McpServerConfig> = {
    prscnt: {
      type: "http",
      url: PRSCNT_MCP_URL,
      headers: { Authorization: `Bearer ${requireEnv("PRSCNT_API_KEY")}` },
    },
  };

  // --- Your own stack MCPs (optional) -------------------------------------
  // Uncomment and adapt for the tools your team runs on. These use YOUR keys
  // and run on YOUR side.
  //
  // servers.airtable = {
  //   type: "stdio",
  //   command: "npx",
  //   args: ["-y", "airtable-mcp-server"],
  //   env: { AIRTABLE_API_KEY: process.env.AIRTABLE_API_KEY ?? "" },
  // };
  // servers.hubspot = { type: "http", url: "https://your-hubspot-mcp/mcp",
  //   headers: { Authorization: `Bearer ${process.env.HUBSPOT_TOKEN ?? ""}` } };
  // ------------------------------------------------------------------------

  return servers;
}

/** The "Prscnt way" of working, distilled. Keep it. */
const SYSTEM_PROMPT = `You are an orchestration agent for a talent-management agency, running on top of Prscnt.

Operating principles:
- Use Prscnt's intelligence BEFORE you act. Price deals with rate_benchmark / deals_benchmark / comparator_pay_history; vet brands with brand_reliability / brand_fingerprint / negotiation_intel; pick creators with roster_match_brand / creator_brand_fit; check check_exclusivity_conflict before committing a creator.
- Compose the agency's OWN tools through their connectors. When a deal advances, mirror it into their CRM/base and keep the pointer with stack_link_deal. Pull their existing graph in read-only with crm_import.
- Draft outreach in the agency's voice with voice_draft. NEVER send outbound yourself — drafts and contracts go to a human for approval (the runner enforces this; see canUseTool).
- Never fabricate brand intel, contacts, rates, or deal terms. If something isn't known, say so and mark it for research.
- Workspace is resolved from auth — never ask for or pass a workspace id.
- Be concise and factual. Return what you did, what you found, and any actions you are PROPOSING for human approval.`;

/** Headless approval gate: block outbound/destructive tools unless ALLOW_OUTBOUND=1. */
const OUTBOUND_PATTERNS = [/send/i, /delete/i, /draft.*send/i, /reply/i];

const canUseTool: CanUseTool = async (toolName, input) => {
  const looksOutbound = OUTBOUND_PATTERNS.some((re) => re.test(toolName));
  if (looksOutbound && !ALLOW_OUTBOUND) {
    return {
      behavior: "deny",
      message:
        `Blocked '${toolName}' — outbound/destructive actions require human approval. ` +
        `Return it as a PROPOSED action, or set ALLOW_OUTBOUND=1 if you gate approval elsewhere.`,
    };
  }
  return { behavior: "allow", updatedInput: input };
};

export interface RunResult {
  ok: boolean;
  result?: string;
  error?: string;
}

/** Run one orchestration task. `task` is natural language; `context` is whatever your UI knows. */
export async function runTask(
  task: string,
  context: Record<string, unknown> = {},
  abortController?: AbortController,
): Promise<RunResult> {
  try {
    requireEnv("ANTHROPIC_API_KEY");

    const prompt =
      `${task}\n\n` +
      (Object.keys(context).length ? `Context from the portal:\n${JSON.stringify(context, null, 2)}` : "");

    const options: Options = {
      model: MODEL,
      systemPrompt: SYSTEM_PROMPT,
      mcpServers: buildMcpServers(),
      allowedTools: ["mcp__prscnt"], // add your connectors, e.g. "mcp__airtable"
      canUseTool,
      permissionMode: "dontAsk",  // headless: never wait for a TTY prompt
      strictMcpConfig: true,      // only the servers declared above
      persistSession: false,      // stateless task runner
      maxTurns: 20,
      ...(abortController ? { abortController } : {}),
    };

    let result: string | undefined;
    for await (const message of query({ prompt, options })) {
      if (message.type === "result" && message.subtype === "success") {
        result = message.result;
      }
    }
    return { ok: true, result };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  }
}

src/server.ts

import { createServer } from "node:http";
import { runTask, assertEnv } from "./agent.js";

const PORT = Number(process.env.PORT ?? 8787);
const RUNNER_TOKEN = process.env.RUNNER_TOKEN;
const TIMEOUT_MS = Number(process.env.RUNNER_TIMEOUT_MS ?? 300_000);

function send(res: import("node:http").ServerResponse, code: number, body: unknown) {
  const json = JSON.stringify(body);
  res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(json) });
  res.end(json);
}

async function readJson(req: import("node:http").IncomingMessage): Promise<unknown> {
  const chunks: Buffer[] = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > 1_000_000) throw new Error("payload too large");
    chunks.push(chunk as Buffer);
  }
  if (chunks.length === 0) return {};
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}

const server = createServer(async (req, res) => {
  try {
    const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);

    if (req.method === "GET" && url.pathname === "/healthz") {
      return send(res, 200, { ok: true });
    }

    if (req.method === "POST" && url.pathname === "/run") {
      if (RUNNER_TOKEN && req.headers["x-runner-token"] !== RUNNER_TOKEN) {
        return send(res, 401, { ok: false, error: "unauthorized" });
      }
      let payload: { task?: unknown; context?: unknown };
      try {
        payload = (await readJson(req)) as typeof payload;
      } catch (err) {
        return send(res, 400, { ok: false, error: err instanceof Error ? err.message : "bad request" });
      }
      if (typeof payload.task !== "string" || payload.task.trim() === "") {
        return send(res, 400, { ok: false, error: "`task` (string) is required" });
      }
      const context = (payload.context && typeof payload.context === "object" ? payload.context : {}) as Record<string, unknown>;
      const ac = new AbortController();
      const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
      try {
        const result = await runTask(payload.task, context, ac);
        return send(res, result.ok ? 200 : 500, result);
      } finally {
        clearTimeout(timer);
      }
    }

    send(res, 404, { ok: false, error: "not_found", routes: ["GET /healthz", "POST /run"] });
  } catch (err) {
    if (!res.headersSent) send(res, 500, { ok: false, error: err instanceof Error ? err.message : "internal error" });
  }
});

try {
  assertEnv(); // fail fast at startup
} catch (err) {
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
  process.exit(1);
}

server.listen(PORT, () => {
  process.stdout.write(`prscnt orchestration runner listening on :${PORT}\n`);
});

Run it

cp .env.example .env     # set ANTHROPIC_API_KEY + PRSCNT_API_KEY
npm install
npm run dev              # :8787

Call it:

curl -s localhost:8787/run -H 'content-type: application/json' \
  -d '{"task":"Advance the Nike deal to contract sent","context":{"recordId":"rec123"}}'

task is a natural-language instruction. context is whatever your UI knows — a record id, a deal id, a brand name. The agent figures out which tools to call.

Add your own connectors

By default the runner wires only the Prscnt MCP. To let the agent write back into your stack, add your connectors in src/agent.tsbuildMcpServers() (uncomment the examples there), then add each server to allowedTools (e.g. "mcp__airtable"). They run with your credentials, on your machine.

The approval gate

The runner is headless — no human watching the chat — so canUseTool stands in for the approval prompt. By default it allows read/intel and pipeline work and blocks anything that sends or deletes externally, returning it as a proposed action for a human to approve in your UI. Flip ALLOW_OUTBOUND=1 only if you gate approval somewhere else. This keeps Prscnt's "drafts, never sends" rule intact even without a human in the loop.

Trigger it from anywhere

The runner is just HTTP — the call is identical across products; only the trigger changes:

UI Trigger
Softr Button → automation → API request
Retool REST query resource
Airtable Automation → script / webhook
Custom app A server route that POSTs
Cron / queue A scheduled job

Deploy it

It's a standard Node service — host it anywhere you'd host a small webhook. Two rules before you expose it publicly:

The template also fails fast at startup if keys are missing and aborts any run past RUNNER_TIMEOUT_MS (default 5 minutes) so a stuck task can't be retried into duplicate work.

Want the full skill set?

The template wires the MCP plus a concise orchestration system prompt — enough to work out of the box. For Prscnt's complete skill and subagent set (morning, pitch, redline, roster, …), install the plugin and load it via the Agent SDK plugins option. Same brain, more depth.

Next