Activity Endpoints
The workspace change log: what changed, who changed it, and when.
Activity answers “what changed in this workspace”. Its sibling, Traces, answers “what did the agents do”. The two deliberately do not overlap — see Activity vs Traces.
Workspace scoping
There is no workspace id in the path. The workspace comes from your credential:
- an API key has one workspace baked in at creation
- a session token selects one with the
x-workspace-idheader, defaulting to your personal workspace
A path parameter could not select a different workspace — it could only disagree with the credential and be rejected — so it would be a parameter that can fail but never do anything.
List activity
GET /api/v1/activity
| Query | Type | Description |
|---|---|---|
type | string | Comma-separated. Each token is a family (object, member, proposal) or a full type (object.created). Unknown token → 400. |
kind | string | Comma-separated subset of skill,agent,workflow,artifact. Only ever matches object.* rows. Unknown value → 400. |
actor | uuid | Only rows caused by this user. |
limit | number | Default 30, clamped to 1..100. |
cursor | string | Opaque keyset cursor from the previous page’s nextCursor. |
curl "https://clanker.net/api/v1/activity?type=object&kind=skill&limit=20" \
-H "x-api-key: ck_your_key"
{
"items": [
{
"id": "9f1c2a80-7d3e-4b21-9a55-0c8f2b1e4d77",
"type": "object.updated",
"at": "2026-08-01T10:12:03.441Z",
"actor": {
"userId": "3ab91c22-…",
"name": "Ada Lovelace",
"kind": "user"
},
"object": {
"kind": "skill",
"id": "pdf-splitter",
"path": "workspaces/7c2e…/skills/pdf-splitter/"
},
"summary": "Updated skill «pdf-splitter»",
"payload": {}
}
],
"nextCursor": "MjAyNi0wOC0wMVQwOTo0NDowMi4wMDFafDFiMGQ1NWU0"
}
nextCursor is null on the last page. Pagination is keyset, not offset:
this is an append-only log with writes landing at the head continuously, so an
offset page would repeat rows as soon as anything happened between two fetches.
The row shape
| Field | Notes |
|---|---|
type | One of the types in the table below. |
at | ISO-8601 UTC. |
actor | kind is user (a person), agent (an agent-initiated write with no attributable human), or system (seeding, migrations). name is resolved server-side. |
object | Non-null for object.* only. path is the R2 key prefix the mutation touched, with a trailing slash. |
payload | Always present. {} for object.*; see the table for the rest. |
summary | A rendered, self-contained phrase carrying no actor name — prefix actor.name yourself if you want “Ada updated…”. |
Types
| Type | Family | payload |
|---|---|---|
object.created | object | {} |
object.updated | object | {} |
object.deleted | object | {} |
member.joined | member | { userId } |
member.left | member | { userId } |
member.removed | member | { userId } |
member.role_changed | member | { userId, role, previousRole } |
proposal.created | proposal | { toolName, toolCallId, runId } |
proposal.approved | proposal | { toolCallId, runId } |
proposal.rejected | proposal | { toolCallId, runId } |
member.left and member.removed are distinct on purpose: leaving is
self-service, being removed is done to you. Collapsing them would make the log
unable to answer who decided.
There is no execution.*. Runs are traces.
Live tail
GET /api/v1/activity/subscribe-url
Returns a short-lived signed URL for this workspace’s realtime channel:
{ "url": "wss://workspace.clanker.net/internal/workspace/<id>/subscribe?ts=…&sig=…", "expiresInMs": 300000 }
Open that URL as a WebSocket. It carries exactly one frame type, delivered
as a bare JSON object (no SSE data: framing):
{"type":"activity.appended","item":{ "…identical to a list item…": true }}
The workspace is taken from your credential, never from the URL — you cannot request a channel you are not a member of. The signature is valid for five minutes and is checked only at the upgrade, so a long-lived connection is unaffected; fetch a fresh URL on each reconnect.
The item is byte-identical to a row from GET /api/v1/activity, so a
client can prepend it into page one without a refetch and without a second
renderer. The connection is a WebSocket to the edge, so there is no keep-alive
comment and no server-side relay.
There is no replay and no backfill. On reconnect, refetch page one — the durable row is always written before the frame is broadcast, so nothing is lost, only delayed.
Activity vs Traces
| Activity | Traces | |
|---|---|---|
| Answers | What changed | What agents did |
| Rows | R2 object mutations, membership, approvals | Runs, turns, tool calls, cost |
| Endpoint | /api/v1/activity | /api/v1/traces |
| Scope | Visible to the whole workspace | Visible to the whole workspace |
These used to overlap: both recorded run lifecycle, which is one fact with two writers that could drift apart. The split is enforced at the type level — the activity vocabulary has no member that can express a run.
If you want “what has the workspace been doing”, read both and merge on timestamp. If you want “who deleted that skill”, read activity. If you want “what did that run cost”, read traces.
Permissions
Membership in the workspace is the entire rule — every member reads everything. There is no per-row scope and no sharing table.
OAuth clients need the activity:read scope. It is not marked sensitive:
activity is metadata about changes (what, by whom, when), not the content of
anyone’s conversation. trace:read is the sensitive one, because it exposes
transcripts. Keeping them separate is the point of having two scopes.