Guest Access

A guest token is a short-lived, revocable credential for someone who has no account — a helper who opens a link on their phone. It is bound to one Job and to either one resource (the tasks that use it) or an explicit list of tasks, and it can only perform the actions it was minted with.

Planning v2 availability. Merged to main and deployed to production (platform.clockwork-co.com; F1 to F7 and F9 to F11 on 2026-10-09, F12 pool assignment and the security fixes on 2026-10-12). The platform flag CLOCKWORK_PLANNING_V2 is currently on globally, in production and in preview, as of 2026-10-12: every organization gets planning-v2 behavior, and with the flag off the planning-v2 endpoints answer 404. Per-organization gating (CLOCKWORK_PLANNING_V2=orgs) is merged but not enabled in production, so the global flag is the only access control in effect. Exact dates and commits: Changelog.

Guest tokens need the platform flag CLOCKWORK_PLANNING_V2 (on in production and preview as of 2026-10-12). With the flag off they do not authenticate (401).

1. Mint a token

typescript
const { data } = await client.tokens.create({
  workflowInstanceId: job.id,
  resourceId: helper.id,                       // or: taskIds: [taskA.id, taskB.id]
  actions: ['tasks:read', 'tasks:complete', 'tasks:delay', 'conversations:write'],
  ttlSeconds: 6 * 3600,                        // 60 s .. 30 days, default 24 h
  rateLimit: 60,                               // requests / minute for this token, default 120
  name: 'Sam — Saturday',
})
 
data.token   // "cg_live_…" — shown ONCE. Only a hash is stored; it cannot be retrieved later.

Exactly one of resourceId or taskIds is required. You must be a member of the Job's project, and every task or resource named must belong to that Job / project — a token can never reach beyond what you could already see.

2. Use it

Use the token as the apiKey of a client (or as a Bearer token):

typescript
const guest = new Clockwork({ apiKey: 'cg_live_…' })
 
await guest.tasks.list()                      // only the tasks in scope
await guest.tasks.complete(taskId)            // 404 for a task outside the scope
await guest.tasks.delay(taskId, { delayMinutes: 15 })
 
for await (const e of guest.events.stream()) { /* filtered, see below */ }
ActionAllows
tasks:readGET /tasks, GET /tasks/{id}, GET /events/stream
tasks:completePOST /tasks/{id}/complete
tasks:delayPOST /tasks/{id}/delay
conversations:writePOST /conversations (on an in-scope task) and POST /conversations/{id}/messages

Everything else returns 403 — guests are denied everywhere unless a route explicitly opts in. That includes creating, editing or deleting tasks, resources, dependencies, constraints, webhooks, simulation, and minting tokens.

What a guest sees is trimmed: a task shows its name, status, times and duration, but not people, owners or internal fields. Completing a task never reveals downstream tasks. A task or conversation outside the scope is a 404, never a 403, so ids cannot be probed. Messages a guest posts are always role user, can't name an actor, and are capped at 4,000 characters.

3. Filtered event streams

A guest's GET /events/stream is pinned to its token's project and Job (a workflowInstanceId in the query is ignored). It carries only task events for in-scope tasks, and schedule.changed events narrowed to the in-scope tasks (dropped when none of the moved tasks are in scope). Conversation, workflow and every other event is never delivered. A resource-scoped token follows the resource: if a task is later assigned to it, its events start flowing.

A guest stream behaves like any other stream (Events): about a second of lag, it ends after 270 seconds so the client reconnects with Last-Event-ID or sinceSequence, and it counts as one request in the token's own rate limit.

The same filters exist for webhooks, for ordinary (non-guest) integrations. Webhooks are one attempt per event with no retries and no ordering guarantee, so a receiver that must not miss anything also reads events.replay() (Webhooks: delivery guarantees):

typescript
await client.events.subscribe({
  url: 'https://hooks.example.com/helper',
  events: ['schedule.changed', 'task.completed'],
  workflowInstanceId: job.id,   // only this Job
  resourceId: helper.id,        // only tasks using this resource; schedule.changed is narrowed to them
})

4. Revoke, expiry and limits

typescript
await client.tokens.revoke(token.id)   // effective on the very next request
await client.tokens.list({ workflowInstanceId: job.id })   // never includes the secret
  • Immediate. Every request re-reads the token; there is no cache, so revocation and expiry apply at once. An event stream that is already open stops within about a second of the token being revoked or expiring.
  • Per-token rate limit in its own bucket (default 120/min, 1 to 1,000). It does not consume any API key's budget. Without Redis configured on the platform the limit is counted in each server instance's memory (checked: a token with rateLimit: 3 was refused on its fourth request), so the effective budget is the limit times the number of warm instances. A 429 carries RateLimit-* headers and no Retry-After (Authentication).
  • Delay is forward only. A guest cannot move a task earlier than it is now; reason is cut to 500 characters.
  • lastUsedAt is refreshed at most once a minute.
  • Short lifetimes. The maximum is 30 days; mint a new token rather than extending one.

Security notes

  • Treat the token like a password: send it over HTTPS only and avoid putting it in URLs that get logged. Prefer a link that exchanges a short code for the token server-side.
  • Only a SHA-256 hash is stored; the lookup is a constant-time comparison.
  • A guest request is executed on behalf of the user who minted the token, but only on routes that opt in, and only after the task (or conversation) is verified to be in scope.