Search the docs

Search guides, API endpoints and MCP tools.

Create tickets from a script

Turn a list of work into tickets on a team, in a project and milestone, each with its Scope, without creating duplicates.

This example reads a list of work from a JSON file and creates a ticket on the Web team for each item, in the docs-launch project, and writes each ticket's Scope. It skips items that already have a ticket with the same title, so you can run it again safely. It uses TypeScript with Node's built-in fetch; any language that speaks HTTP works the same way.

Before you begin

  • An API key in CLANKER_API_KEY: your own, or an account API key with the member role. See Authentication.
  • The team (WEB) and the project (docs-launch), with the team linked to the project. A ticket joins a project only when its team is linked to it.
  • Tag names that exist for the team. List tags with team=WEB shows them.
  • The team's default status keeps the Scope document, as Backlog does in every new workspace. See Documents.

The input

json
[
  {
    "title": "Add docs search",
    "scope": "## Goal\n\nReaders find any page from the docs header.\n\n## Background\n\nReaders cannot find a page without browsing the sidebar.",
    "milestone": "M2",
    "tags": ["feature"]
  },
  {
    "title": "Fix broken links in the API reference",
    "scope": "## Goal\n\nEvery link in the API reference opens its page.\n\n## Background\n\nLinks from the Tickets page to the Ticket object return 404.",
    "tags": ["bug"]
  }
]

Save it as tickets.json. scope is the ticket's Scope in Markdown, up to 200,000 characters, and milestone names one of the project's milestones, such as M2.

The script

typescript
// create-tickets.mts: npx tsx create-tickets.mts tickets.json
import { readFile } from "node:fs/promises";

const API = "https://api.clankership.com/v1/workspaces/acme";
const TEAM = "WEB";
const PROJECT = "docs-launch";
const headers = {
  Authorization: `Bearer ${process.env.CLANKER_API_KEY}`,
  "Content-Type": "application/json",
};

type Item = { title: string; scope?: string; milestone?: string; tags?: string[] };

async function call<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(`${API}${path}`, { headers, ...init });
  const body = await response.json();
  // Errors come back as { _tag, message }, such as { _tag: "InvalidInput", message: "…" }.
  if (!response.ok) throw new Error(`${response.status} ${body._tag}: ${body.message}`);
  return body as T;
}

const items: Item[] = JSON.parse(await readFile(process.argv[2]!, "utf8"));

// The project's tickets on the team, so a second run skips what the first one created.
const { tickets } = await call<{ tickets: { identifier: string; title: string }[] }>(
  `/tickets?team=${TEAM}&project=${PROJECT}`,
);
const existing = new Map(tickets.map((ticket) => [ticket.title, ticket.identifier]));

for (const item of items) {
  const found = existing.get(item.title);
  if (found) {
    console.log(`${found} already exists: ${item.title}`);
    continue;
  }
  const { scope, ...fields } = item;
  const ticket = await call<{ identifier: string }>("/tickets", {
    method: "POST",
    body: JSON.stringify({ team: TEAM, project: PROJECT, ...fields }),
  });
  // A new ticket's Scope is at revision 0, its template, until the first write.
  if (scope)
    await call(`/tickets/${ticket.identifier}/documents/scope`, {
      method: "PUT",
      body: JSON.stringify({ body: scope, baseRevision: 0 }),
    });
  console.log(`${ticket.identifier} created: ${item.title}`);
}

Run it:

bash
export CLANKER_API_KEY="ctk_…"
npx tsx create-tickets.mts tickets.json
text
WEB-31 created: Add docs search
WEB-32 created: Fix broken links in the API reference

The .mts extension makes the file an ES module, so it can use top-level await.

How it behaves

  • Create a ticket gives each ticket the team's next number and puts it at the end of the workspace's ticket order. Numbers are never reused.
  • A ticket gets the team's default status unless you send status, by name, such as "Plan". The defaults in the team's clanker instructions apply only to tickets that clankers create.
  • Creating a ticket is not idempotent: sending the same request twice creates two tickets. That is why the script lists the project's tickets first. See Idempotent requests.
  • Write a ticket document with baseRevision: 0 writes the first revision of the Scope. Once anyone has written it, a write from revision 0 fails with 409 Conflict, so the script never replaces a Scope someone changed.
  • A bad item fails with a message that names the problem: 400 InvalidInput when the team is not linked to the project, or 404 NotFound for a tag or milestone that does not exist. The script stops at the first error, keeping the tickets it already created.

Variations

  • Give each ticket an owner and clankers. Send owner with a member's email, and clankers with clanker IDs from List clankers.

  • Send the new tickets to a clanker. Send a message with ask: "draft" asks the clanker to write each Scope from the status's instructions.

  • One ticket from the shell. The CLI does the same for one item:

    bash
    clanker ticket create --team WEB --title "Add docs search" --project docs-launch \
      --milestone M2 --tag feature
    clanker ticket doc write WEB-31 scope --file scope.md