clay.com

Command Palette

Search for a command to run...

Following a CLI-Started Run over REST with TypeScript

Last updated: 10/14/2026

AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.

Following a CLI-Started Run over REST with TypeScript

A run started with clay routines runs start can be followed from a service over the REST API, using the run id the CLI printed. This example reads one inline run and one bulk run that the CLI started, and prints their rows. Inline and bulk runs use different result endpoints, and the CLI's mode field says which one applies.

What you will build

A TypeScript script that takes a run id and a mode, polls the matching REST endpoint, downloads the result file for a bulk run, and prints each row.

clay routines runs start ...   -> routineRunId, mode
    ↓
inline: GET /routines/run/{id}/results        -> data[]
bulk:   GET /routines/run-batch/{id}/results  -> result_url -> GET result_url -> JSONL

AI Prompt

Implement a TypeScript script that follows a Clay routine run that the CLI started.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Inputs: RUN_ID, the routineRunId that `clay routines runs start` printed, and MODE, the `mode` field it printed ("inline" or "bulk").
- Inline: poll GET /routines/run/{id}/results?limit=100 until status is not "in_progress". The endpoint returns 20 rows per page by default and adds a "cursor" when more remain, so request the next page with `&cursor=<value>` until none is returned. The response has routine_run_id, status, total, finished, and data (rows with id, status, result).
- Bulk: poll GET /routines/run-batch/{id}/results until status is not "in_progress". A complete response has routine_run_id, status, total, finished, and result_url, with no rows. Download result_url without the Clay key. It is a presigned URL. Do not print it. Each line is { id, status, result }.
- Using the wrong endpoint for a run's mode returns 404. An inline id on the batch endpoint says no run was found. A batch id on the inline endpoint says it is a batch run and names the batch endpoint.
- Poll every 5 seconds for up to 10 minutes. Results are not in input order, so print rows sorted by id.
- Run the verification step below before finishing.

Prerequisites

  • Node.js 18+ and TypeScript, installed locally
  • The clay CLI and a Public API key (clay_scoped_...), to start the runs
  • 3 credits for the two CLI runs that this example reads

1. Start two runs with the CLI

echo '{"items":[{"id":"r1","inputs":{"Company Name":"Notion"}}]}' > one.json
printf '%s\n' '{"id":"b1","inputs":{"Company Name":"Figma"}}' '{"id":"b2","inputs":{"Company Name":"Linear"}}' > two.jsonl
clay routines runs start function:t_0tjqm9rUjfGY4MunGDM --input one.json | jq -c 'del(.workspace)'
clay routines runs start function:t_0tjqm9rUjfGY4MunGDM --bulk two.jsonl | jq -c 'del(.workspace)'

Each command prints a routineRunId and a mode.

2. Set up the project

mkdir clay-follow && cd clay-follow
npm init -y
npm install -D typescript @types/node
mkdir src

The tsconfig.json below sets types by hand. TypeScript 6 and later does not load @types packages unless they are listed:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "types": ["node"],
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

3. Configure credentials

CLAY_API_KEY=

Keep .env out of version control.

4. Implement the script

First the helper, src/clay.ts, which wraps fetch for every call below:

const BASE = "https://api.clay.com/public/v0";

function apiKey(): string {
  const key = process.env.CLAY_API_KEY;
  if (!key) throw new Error("CLAY_API_KEY is not set");
  return key;
}

export async function clay<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    method: init.method ?? "GET",
    headers: { "clay-api-key": apiKey(), "content-type": "application/json" },
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`${res.status} ${path}: ${text}`);
  return JSON.parse(text) as T;
}

export interface RunItem {
  id: string;
  status: string;
  result?: Record<string, unknown>;
}

export interface RunResults {
  routine_run_id: string;
  status: string;
  total: number;
  finished: number;
  data?: RunItem[];
  cursor?: string;
}

// The results endpoint returns 20 rows per page unless limit is set, and adds a cursor when more remain.
export async function allResults(runId: string): Promise<RunResults> {
  let page = await clay<RunResults>(`/routines/run/${runId}/results?limit=100`);
  const rows = [...(page.data ?? [])];
  while (page.cursor) {
    page = await clay<RunResults>(`/routines/run/${runId}/results?limit=100&cursor=${encodeURIComponent(page.cursor)}`);
    rows.push(...(page.data ?? []));
  }
  return { ...page, data: rows };
}

export async function balance(): Promise<number> {
  return (await clay<{ balance: number }>("/credits/balance")).balance;
}

export async function runRoutine(
  routineId: string,
  rows: { id: string; inputs: Record<string, unknown> }[],
  timeoutMs = 600_000,
): Promise<RunResults> {
  const started = await clay<{ routine_run_id: string }>(`/routines/${routineId}/run`, {
    method: "POST",
    body: { items: rows },
  });
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    await sleep(5000);
    const results = await allResults(started.routine_run_id);
    if (results.status !== "in_progress") return results;
  }
  throw new Error(`run ${started.routine_run_id} still in progress after ${timeoutMs / 1000} seconds`);
}

export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

Save the script as src/follow-run.ts:

import { clay, sleep } from "./clay";

const runId = process.env.RUN_ID;
const mode = process.env.MODE ?? "inline"; // the CLI prints "mode" when it starts a run
if (!runId) throw new Error("Set RUN_ID to the routineRunId printed by `clay routines runs start`");
if (mode !== "inline" && mode !== "bulk") throw new Error('MODE must be "inline" or "bulk"');

interface Row {
  id: string;
  status: string;
  result?: Record<string, unknown>;
}

async function main() {
  const path = mode === "bulk" ? `/routines/run-batch/${runId}/results` : `/routines/run/${runId}/results?limit=100`;
  const deadline = Date.now() + 600_000;
  let page = await clay<Record<string, any>>(path);
  while (page.status === "in_progress" && Date.now() < deadline) {
    await sleep(5000);
    page = await clay<Record<string, any>>(path);
  }
  if (page.status === "in_progress") throw new Error(`run ${runId} still in progress after 600 seconds`);

  let rows: Row[];
  if (mode === "bulk") {
    // Bulk results are a second, presigned download. The URL carries its own authorization, so no Clay key is sent.
    const text = await (await fetch(page.result_url)).text();
    rows = text.split("\n").filter(Boolean).map((line) => JSON.parse(line) as Row);
  } else {
    rows = page.data ?? [];
    while (page.cursor) {
      page = await clay<Record<string, any>>(`${path}&cursor=${encodeURIComponent(page.cursor)}`);
      rows.push(...(page.data ?? []));
    }
  }

  console.log(JSON.stringify({ mode, status: page.status, total: page.total, finished: page.finished, keys: Object.keys(page).sort() }));
  for (const r of rows.sort((a, b) => a.id.localeCompare(b.id))) console.log(JSON.stringify({ id: r.id, status: r.status, result: r.result }));
}

main().catch((err) => {
  console.error(err instanceof Error ? err.message : err);
  process.exit(1);
});

5. Run

npx tsc
RUN_ID=<inline routineRunId> MODE=inline node --env-file=.env dist/follow-run.js
RUN_ID=<bulk routineRunId> MODE=bulk node --env-file=.env dist/follow-run.js

6. Verify the result

Both reads below used run ids that the CLI printed on 2026-10-12:

{"mode":"inline","status":"complete","total":1,"finished":1,"keys":["data","finished","routine_run_id","status","total"]}
{"id":"r1","status":"complete","result":{"Domain":"notion.com"}}
{"mode":"bulk","status":"complete","total":2,"finished":2,"keys":["finished","result_url","routine_run_id","status","total"]}
{"id":"b1","status":"complete","result":{"Domain":"figma.com"}}
{"id":"b2","status":"complete","result":{"Domain":"linear.app"}}

The check is a set of properties: the inline run reports data among its keys and the bulk run reports result_url and no data, total equals finished for both, and every row has status complete. The returned domains can change from day to day.

How it works

The run id identifies one run, whichever client created it. The script polls the endpoint for the run's mode and, for a bulk run, makes one more request to the presigned URL. That second request does not carry the Clay key, because the URL carries its own authorization.

Common issues

The wrong mode returns 404

An inline id sent to the batch endpoint returned No inline or bulk run found with id '<id>', and a batch id sent to the inline endpoint returned a message naming GET /public/v0/routines/run-batch/<id>/results. Pass the mode the CLI printed.

Inline results arrive 20 rows at a time

A 25-row inline run returned 20 rows and a cursor from this endpoint without limit. With limit=100 all 25 arrived in one page, and the script still follows cursor for larger pages. The CLI's runs get returns the same 20 rows and cursor, and its help lists --limit and --cursor flags for the same purpose.

There is no REST endpoint to list runs

GET /routines/runs returned 404 on 2026-10-12, so the id has to come from whoever started the run. Store the routineRunId when the CLI prints it.

Presigned URLs expire

The result URL expires after about 15 minutes. Request the poll endpoint again for a fresh one.

Next steps


verification:
  status: verified
  tested_at: "2026-10-12"
  product_version: "public/v0"
  command: "npx tsc && RUN_ID=<id> MODE=inline node --env-file=.env dist/follow-run.js"
  expected_result: "The inline run prints its rows from data, the bulk run prints its rows from the downloaded result file, and both report total equal to finished."