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, andparentIssueIdmust 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
activewithout 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
skippedorcoalesceddo 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 nolinkedIssueId - the run's
triggerPayloadcarriesactivityGate.verdictof"quiet"plusactivityGate.windowStart, the timestamp the gate measured from - the trigger's last result reads
skipped_no_activity, andnextRunAthas 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:
webhookUrlwebhookSecret
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
lastWebhookDeliveryreflects 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:
labelenabledcronExpressionandtimezonefor schedule triggerssigningModeandreplayWindowSecfor webhook triggerssetupPending— only the valuefalseis accepted, which finishes setup and enables the webhook for real deliveries (see Webhook setup and test deliveries)archived—truearchives the trigger (retires it from the active set),falserestores 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
webhookUrlandwebhookSecretmaterial
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
publicIdmust 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_webhookuses eitherAuthorization: Bearer <secret>, orX-Hub-Signature/X-Hub-Signature-256carryingsha256=<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.bearerusesAuthorization: Bearer <secret>hmac_sha256usesX-Paperclip-SignatureplusX-Paperclip-Timestamp, signing the timestamp, a dot, and the exact bodygithub_hmacusesX-Hub-Signature-256, falling back toX-Paperclip-Signature, over the exact body with no timestampfireflies_hmacusesX-Hub-Signatureover the exact bodynonedoes 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:
statussourcetriggerIdtriggerPayloadlinkedIssueIdcoalescedIntoRunIdfailureReasoncompletedAt
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
nextRunAtautomatically. - 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
skippedrun 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 askippedrun and is not backfilled later.
If you are wiring this from code, the common path is:
- create the routine
- attach a schedule or webhook trigger
- inspect the created webhook material if needed
- list runs to confirm the routine is firing as expected