Routines

Routines are Paperclip's recurring execution layer. Use them when you want an agent to run on a schedule, respond to a webhook, or be kicked off manually through the API.

A routine does not do the work itself. It creates a run, and that run usually creates or links to an execution issue for the assigned agent.


What A Routine Controls

A routine ties together:

  • the agent that owns the work
  • the project, goal, and optional parent issue context
  • the title and description template for the execution issue
  • the trigger or triggers that start runs
  • the concurrency policy when another run is already active
  • the catch-up policy for missed schedule ticks
  • the activity gate that decides whether a scheduled tick should fire at all on a quiet week

Routine status values are:

Status Meaning
active The routine can fire and create runs.
paused The routine is stored but does not fire automatically.
archived The routine is retired. Archived routines do not fire.

You cannot have an active routine without an assignee agent. If you try to create or enable one without an assignee, the API normalizes it to paused or rejects the activation.


List Routines

GET /api/companies/{companyId}/routines

Returns all routines for the company, newest updates first.

Each list item includes:

  • the routine fields
  • trigger summaries
  • the latest run
  • the current active execution issue, if one exists

Example

curl "http://localhost:3100/api/companies/company-1/routines" \
  -H "Authorization: Bearer <token>"
const res = await fetch("/api/companies/company-1/routines", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

const routines = await res.json();

import requests

response = requests.get( "http://localhost:3100/api/companies/company-1/routines", headers={ "Authorization": f"Bearer {token}", }, )

routines = response.json()


Get Routine

GET /api/routines/{routineId}

Returns one routine with:

  • project details
  • assigned agent details
  • parent issue details
  • triggers
  • recent runs
  • current active execution issue

This endpoint is company-scoped. If the routine is not in your company, the request is rejected.


Create Routine

POST /api/companies/{companyId}/routines
Content-Type: application/json

Body:

Field Required Notes
title yes Routine name. Can use routine variables in the text.
description no Human-readable routine description. Can also use variables.
assigneeAgentId yes Agent who receives each run.
projectId no Project to associate with the routine.
goalId no Goal to link routine-created work to.
parentIssueId no Parent issue for routine-created execution issues.
priority no critical, high, medium default, low.
status no Defaults to active when an assignee exists, otherwise paused.
concurrencyPolicy no Defaults to coalesce_if_active.
catchUpPolicy no Defaults to skip_missed.
activityGatePolicy no always (default) or require_external_activity. See Activity Gate.
activityGateScope no company (default) or project. Only used when the policy is require_external_activity.
variables no Template variables for the routine title, description, and run payloads.

Agents can only create routines assigned to themselves. Board users can create routines for any agent they can assign.

Important behavior:

  • projectId, goalId, and parentIssueId must belong to the same company.
  • assigned agents must belong to the same company and must not be terminated or pending approval.
  • if you mark a routine active without an assignee, the API will not leave it active.
  • if the title or description references variables, those variables must be valid and resolvable.

Example

curl -X POST "http://localhost:3100/api/companies/company-1/routines" \
  -H "Authorization: Bearer <board-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Weekly CEO briefing for {{company_name}}",
    "description": "Compile a weekly summary and prepare the next priorities.",
    "assigneeAgentId": "agent-1",
    "projectId": "project-1",
    "goalId": "goal-1",
    "priority": "medium",
    "status": "active",
    "concurrencyPolicy": "coalesce_if_active",
    "catchUpPolicy": "skip_missed",
    "variables": [
      {
        "name": "company_name",
        "label": "Company name",
        "type": "text",
        "required": true,
        "defaultValue": "Paperclip"
      }
    ]
  }'
const res = await fetch("/api/companies/company-1/routines", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${boardToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    title: "Weekly CEO briefing for {{company_name}}",
    description: "Compile a weekly summary and prepare the next priorities.",
    assigneeAgentId: "agent-1",
    projectId: "project-1",
    goalId: "goal-1",
    priority: "medium",
    status: "active",
    concurrencyPolicy: "coalesce_if_active",
    catchUpPolicy: "skip_missed",
    variables: [
      {
        name: "company_name",
        label: "Company name",
        type: "text",
        required: true,
        defaultValue: "Paperclip",
      },
    ],
  }),
});

const routine = await res.json();

import requests

response = requests.post( "http://localhost:3100/api/companies/company-1/routines", headers={ "Authorization": f"Bearer {board_token}", "Content-Type": "application/json", }, json={ "title": "Weekly CEO briefing for {{company_name}}", "description": "Compile a weekly summary and prepare the next priorities.", "assigneeAgentId": "agent-1", "projectId": "project-1", "goalId": "goal-1", "priority": "medium", "status": "active", "concurrencyPolicy": "coalesce_if_active", "catchUpPolicy": "skip_missed", "variables": [ { "name": "company_name", "label": "Company name", "type": "text", "required": True, "defaultValue": "Paperclip", } ], }, )

routine = response.json()


Update Routine

PATCH /api/routines/{routineId}
Content-Type: application/json

All create fields are updatable.

Agent access is narrower than board access:

  • an agent can only update routines assigned to itself
  • an agent cannot reassign a routine to a different agent
  • if an agent tries to enable a routine for someone else, the API rejects it

You can use this endpoint to:

  • pause or resume a routine
  • change the schedule context
  • move it to another project or goal
  • adjust variables or priority
  • turn the activity gate on or off

Activity Gate

By default a routine fires on every scheduled tick, whether or not anything has happened in your company since the last one. The activity gate lets you say "only run me if there is something new to react to" — handy for digests, sweeps, and triage routines that have nothing useful to do on a quiet week.

Two fields control it:

Field Values Default
activityGatePolicy always, require_external_activity always
activityGateScope company, project company

With require_external_activity, every scheduled tick looks for at least one activity-log entry that:

  • was recorded after the routine's last dispatched run, and no later than the current tick — runs that ended up skipped or coalesced do not move that starting point
  • is not one of the read and inbox bookkeeping actions issue.read_marked, issue.read_unmarked, issue.inbox_archived, issue.inbox_unarchived
  • was not written by the routine scheduler about this same routine
  • did not come from an agent run working on an execution issue this routine created

If one matching entry exists, the tick dispatches normally. If none does, the tick is skipped.

The last two rules are what makes "external" mean external: a routine's own runs, and its own skip records, can never keep it armed.

The first ever tick always fires. With no dispatched run behind it there is no window to measure.

Scope

  • company (default) — activity anywhere in the company counts.
  • project — only activity tied to the routine's project counts: the project record itself, issues in it, routines and routine runs belonging to it, agent runs working on its issues, and any activity entry that carries the project id.

A routine set to activityGateScope: "project" without a projectId can never match anything, so it will never fire on a schedule. Set a project first.

What a gated-off tick looks like

Nothing fails, and nothing is queued for later:

  • a run row is written with status: "skipped", failureReason: "no_external_activity", and no linkedIssueId
  • the run's triggerPayload carries activityGate.verdict of "quiet" plus activityGate.windowStart, the timestamp the gate measured from
  • the trigger's last result reads skipped_no_activity, and nextRunAt has already advanced to the following tick

The skipped tick is not backfilled when activity resumes. The next scheduled tick is evaluated on its own.

What the gate does not affect

The gate is only consulted for scheduled ticks. Webhook firings and manual runs through POST /api/routines/{routineId}/run dispatch regardless of the policy.


Trigger Types

Routines support three trigger kinds:

Schedule

POST /api/routines/{routineId}/triggers
Content-Type: application/json
{
  "kind": "schedule",
  "label": "Monday morning",
  "enabled": true,
  "cronExpression": "0 9 * * 1",
  "timezone": "Europe/Amsterdam"
}

Schedule triggers use cron plus a timezone. The server validates the cron expression and computes the next run time in that timezone.

Schedule triggers only work if the routine's required variables can be resolved. If the routine needs required variables without defaults, the schedule trigger cannot be enabled.

Webhook

{
  "kind": "webhook",
  "label": "Stripe event bridge",
  "enabled": true,
  "signingMode": "bearer",
  "replayWindowSec": 300
}

Webhook triggers create a public URL and a secret. The public URL is returned once when the trigger is created or when the secret is rotated.

Supported signing modes in the code are:

Mode What it accepts
app_webhook Either a bearer token or an HMAC-SHA256 signature over the exact request body. This is the mode the UI's Another app or script setup creates.
bearer A bearer token only.
hmac_sha256 A timestamped HMAC-SHA256 signature.
github_hmac GitHub-style HMAC-SHA256 over the body, without a timestamp.
fireflies_hmac Fireflies Webhooks V2 signatures. Kept for triggers created while it was offered; new setups use app_webhook instead.
none No authentication. Anyone with the URL can fire the trigger.

When you create a webhook trigger through the API and omit signingMode, the default is bearer.

Replay windows are only used for the timestamped hmac_sha256 mode and must be between 30 and 86,400 seconds. The default is 300 seconds.

The webhookUrl is built from the server's public origin. External senders such as GitHub or Fireflies can only deliver to a publicly reachable HTTPS address — see HTTPS and public access. The UI warns when a URL points at localhost, a private network, a Tailscale .ts.net host, or plain HTTP.

API

{
  "kind": "api"
}

API triggers do not create a public URL. They exist so the routine can be fired explicitly through the routine run endpoint.


Add Trigger

When you create a trigger, the response may include secret material for webhook triggers.

For webhook triggers, the response includes:

  • webhookUrl
  • webhookSecret

Treat the secret like a password. It is stored as a company secret behind the scenes and is only shown to you when the trigger is created or when the secret is rotated.

Example

curl -X POST "http://localhost:3100/api/routines/routine-1/triggers" \
  -H "Authorization: Bearer <board-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "webhook",
    "label": "Webhook intake",
    "enabled": true,
    "signingMode": "bearer"
  }'
const res = await fetch("/api/routines/routine-1/triggers", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${boardToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    kind: "webhook",
    label: "Webhook intake",
    enabled: true,
    signingMode: "bearer",
  }),
});

const created = await res.json();

import requests

response = requests.post( "http://localhost:3100/api/routines/routine-1/triggers", headers={ "Authorization": f"Bearer {board_token}", "Content-Type": "application/json", }, json={ "kind": "webhook", "label": "Webhook intake", "enabled": True, "signingMode": "bearer", }, )

created = response.json()


Webhook Setup And Test Deliveries

A webhook trigger can start life in a setup-pending state. Pass setupPending: true in the create body to opt in; the trigger is stored but treats every inbound delivery as a connection test rather than a live firing.

While a trigger is setup-pending:

  • a delivery to the fire endpoint is validated for signature and content, but does not create a routine run or an execution issue
  • the delivery is recorded as a content-free test receipt, keyed by the request's idempotency key, so a later retry of that same test cannot start the routine after the trigger goes live
  • the trigger's lastWebhookDelivery reflects the outcome so a client can show whether the test arrived

The lastWebhookDelivery object has:

Field Values Notes
status received, rejected received means the signature and payload passed; rejected means authentication failed.
receivedAt ISO timestamp When the delivery landed.
test boolean true for a setup-pending test delivery.

To finish setup, PATCH /api/routine-triggers/{triggerId} with setupPending: false. From that point on, real deliveries create runs. The test delivery you sent during setup is not replayed — only events received after the trigger is enabled fire the routine.


Update Trigger

PATCH /api/routine-triggers/{triggerId}
Content-Type: application/json

You can update:

  • label
  • enabled
  • cronExpression and timezone for schedule triggers
  • signingMode and replayWindowSec for webhook triggers
  • setupPending — only the value false is accepted, which finishes setup and enables the webhook for real deliveries (see Webhook setup and test deliveries)
  • archived — true archives the trigger (retires it from the active set), false restores it

If you enable a schedule trigger, the routine must still have resolvable required variables.


Delete Trigger

DELETE /api/routine-triggers/{triggerId}

Deletes the trigger permanently.


Rotate Trigger Secret

POST /api/routine-triggers/{triggerId}/rotate-secret

Only webhook triggers can rotate secrets.

Rotation behavior:

  • a new secret value is generated
  • the previous secret is invalidated immediately
  • the trigger keeps the same public URL
  • the response includes fresh webhookUrl and webhookSecret material

This is the right endpoint to use if a secret was exposed or if you want to reissue credentials without changing the webhook URL.


Manual Run

POST /api/routines/{routineId}/run
Content-Type: application/json

Body:

Field Required Notes
source no Defaults to manual. The code also accepts api.
triggerId no If present, it must belong to the routine and be enabled.
payload no Arbitrary JSON payload stored on the run.
variables no Explicit variable overrides.
projectId no Overrides the routine's project for this run.
assigneeAgentId no Overrides the routine's default assignee for this run.
idempotencyKey no Deduplicates repeated requests with the same source/trigger combination.
executionWorkspaceId no Optional workspace binding for the created issue.
executionWorkspacePreference no Optional workspace preference for the created issue.
executionWorkspaceSettings no Optional workspace settings object for the created issue.

Manual runs still obey the routine's concurrency policy. If another live execution issue already exists, the run may be marked coalesced or skipped instead of creating a new issue.

If you pass a triggerId, the server checks that:

  • the trigger belongs to the routine
  • the trigger is enabled

The endpoint returns 202 Accepted.

Example

curl -X POST "http://localhost:3100/api/routines/routine-1/run" \
  -H "Authorization: Bearer <board-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "manual",
    "payload": {
      "context": "Run a one-off briefing"
    },
    "idempotencyKey": "briefing-2026-04-15"
  }'
const res = await fetch("/api/routines/routine-1/run", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${boardToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    source: "manual",
    payload: {
      context: "Run a one-off briefing",
    },
    idempotencyKey: "briefing-2026-04-15",
  }),
});

const run = await res.json();

import requests

response = requests.post( "http://localhost:3100/api/routines/routine-1/run", headers={ "Authorization": f"Bearer {board_token}", "Content-Type": "application/json", }, json={ "source": "manual", "payload": { "context": "Run a one-off briefing", }, "idempotencyKey": "briefing-2026-04-15", }, )

run = response.json()


Fire Public Trigger

POST /api/routine-triggers/public/{publicId}/fire

This endpoint is for external systems that call a routine's webhook trigger directly.

The request must be sent with Content-Type: application/json and the body must be a JSON object — an array, a bare string, or a missing body is rejected. Sending the wrong content type returns 415 Unsupported Media Type.

What the code checks:

  • the publicId must match a webhook trigger
  • the trigger must still be enabled
  • the routine must still be active
  • the request must satisfy the trigger's signing mode

Accepted headers depend on signing mode:

  • app_webhook uses either Authorization: Bearer <secret>, or X-Hub-Signature / X-Hub-Signature-256 carrying sha256=<hex> — HMAC-SHA256 of the exact request body. If a signature header is present it must be valid; the server does not fall back to the bearer check.
  • bearer uses Authorization: Bearer <secret>
  • hmac_sha256 uses X-Paperclip-Signature plus X-Paperclip-Timestamp, signing the timestamp, a dot, and the exact body
  • github_hmac uses X-Hub-Signature-256, falling back to X-Paperclip-Signature, over the exact body with no timestamp
  • fireflies_hmac uses X-Hub-Signature over the exact body
  • none does not require a signature

For timestamped HMAC validation, the server enforces the replay window from the trigger.

For app_webhook triggers, the validated JSON payload is appended to the execution issue's description inside a fenced data block, labelled as external data the agent must not treat as instructions. The block is capped at 16,384 characters; the full payload is always stored on the routine run. A signed app_webhook delivery without an idempotency key is deduplicated by a digest of the trigger and the exact body, so an identical signed retry does not start a second run.

fireflies_hmac triggers keep their provider-specific handling: only authenticated meeting metadata (event, meeting_id, timestamp, and an optional client_reference_id) becomes run input, runs are deduplicated per meeting, and events other than meeting.summarized are acknowledged with { "status": "ignored", "routineStarted": false, "linkedIssueId": null } without creating a run.

For deduplication the server reads an idempotency key from the Idempotency-Key header, falling back to X-GitHub-Delivery. Sending the same key on a retry prevents a duplicate run — and, during setup, prevents a retried test delivery from starting the routine once the trigger is enabled.

If the trigger is still setup-pending, the endpoint validates the delivery and records it as a test receipt, but does not create a run.

Example

curl -X POST "http://localhost:3100/api/routine-triggers/public/<public-id>/fire" \
  -H "Authorization: Bearer <webhook-secret>" \
  -H "Content-Type: application/json" \
  -d '{
    "payload": {
      "source": "stripe",
      "event": "invoice.paid"
    }
  }'
const res = await fetch("/api/routine-triggers/public/<public-id>/fire", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${webhookSecret}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    payload: {
      source: "stripe",
      event: "invoice.paid",
    },
  }),
});

const run = await res.json();

import requests

response = requests.post( "http://localhost:3100/api/routine-triggers/public/<public-id>/fire", headers={ "Authorization": f"Bearer {webhook_secret}", "Content-Type": "application/json", }, json={ "payload": { "source": "stripe", "event": "invoice.paid", } }, )

run = response.json()


List Runs

GET /api/routines/{routineId}/runs?limit=50

Returns the most recent routine run history. The limit defaults to 50 and is capped at 200.

Each run includes:

  • status
  • source
  • triggerId
  • triggerPayload
  • linkedIssueId
  • coalescedIntoRunId
  • failureReason
  • completedAt

Use this endpoint when you want to see whether a routine created a new issue, coalesced into an existing execution, or failed before creating work.


Routine Run Statuses

Routine runs use these statuses:

Status Meaning
received The run was accepted and is being processed.
coalesced A live execution already existed, so this run linked to it.
skipped The run did not create work. Either a live execution already existed and the concurrency policy chose to skip, or the routine's project was paused at tick time, or the activity gate found nothing new, or the instance is running in a worktree that is not cleared to execute this routine. Read failureReason to tell them apart — see the table below.
issue_created A new execution issue was created.
completed The execution issue later moved to done.
failed The execution issue failed, was cancelled, or the dispatch failed.

Why a run was skipped

A suppressed automatic firing records a failureReason you can read back from this endpoint, and the trigger's last result shows the matching value:

failureReason Trigger last result Meaning
no_external_activity skipped_no_activity The activity gate found nothing new since the routine's last dispatched run.
paused Skipped because the project is paused The routine's project was paused at tick time.
worktree_execution_cutoff skipped_worktree_execution_cutoff The server is running inside a development worktree (PAPERCLIP_IN_WORKTREE) where automatic run execution is not armed for this routine — either the worktree isn't armed at all, or the routine was created before the worktree's activation cutoff. This applies to scheduled ticks and webhook firings alike.

A run skipped by the concurrency policy carries no failureReason — it records the live execution issue in linkedIssueId instead.

The list view also shows the current active issue for a routine when one exists.

In the UI, a routine's Runs section lists the execution issues the routine created — the same issue list used elsewhere, filtered to this routine's routine_execution issues — so you can search them and change status, priority, or assignee in place. Runs that never created an issue, such as skipped ticks, appear only in this endpoint and in the recent runs on the routine's overview.


Agent Access Rules

Agents can read routines in their company, but they can only manage routines assigned to themselves.

Operation Agent Board
List routines ✅ ✅
Get routine details ✅ ✅
Create routine ✅ own only ✅
Update routine ✅ own only ✅
Add trigger ✅ own only ✅
Update trigger ✅ own only ✅
Delete trigger ✅ own only ✅
Rotate trigger secret ✅ own only ✅
Manual run ✅ own only ✅
Fire public trigger ✅ if the HTTP signature matches ✅

Board operators need tasks:assign permission for actions that assign work to agents other than themselves.


Practical Notes

  • A routine can have multiple triggers.
  • Schedule triggers compute nextRunAt automatically.
  • Webhook triggers mint a company secret behind the scenes.
  • The public webhook URL does not change when you rotate the secret.
  • If a routine is archived, it will not fire again.
  • If the routine's project is paused, scheduled ticks are suppressed: the firing is recorded as a skipped run and the schedule advances to the next tick, but no execution issue is created and the missed tick is not backfilled when the project resumes.
  • If a run finds an active live execution issue and the concurrency policy is not always_enqueue, the run is linked or skipped instead of creating new work.
  • If the routine uses activityGatePolicy: "require_external_activity" and nothing external happened since its last dispatched run, the scheduled tick is recorded as a skipped run and is not backfilled later.

If you are wiring this from code, the common path is:

  1. create the routine
  2. attach a schedule or webhook trigger
  3. inspect the created webhook material if needed
  4. list runs to confirm the routine is firing as expected