API
Workspace endpoints, the context pack response, cursor paging on turns and utterances, and the two internal surfaces the spec does not describe.
Discovery and ops
| Path | Method | Notes |
|---|---|---|
/ | GET | Service discovery (enveloped) |
/api-docs | GET | Swagger UI when SWAGGER_ON=true |
/openapi.yaml, /openapi.json | GET | Spec generated from the Go handler types |
/healthz | GET | Liveness |
/readyz | GET | Readiness (Postgres ping) |
Projects
| Path | Method | Contract |
|---|---|---|
/v1/projects | GET, POST | List and create. Anyone signed in may create a project; they become its owner. Platform admin is not required. A platform admin's list is every project. X-Flow-As-Member: true lists only the projects they belong to; it cannot grant admin. q matches name or description |
/v1/projects/gallery, /v1/project-templates | GET | Discovery surfaces |
/v1/project-templates | POST | Save a template, with the workflow its projects get. See Tasks |
/v1/projects/{id} | GET, PATCH | Read and update. Owners only for the update, which is also where the check-in day is set |
/v1/projects/{id}/status | POST | Archive, restore, withdraw from knowledge |
/v1/projects/{id}/members, /members/{principal} | GET, POST | Membership |
/v1/projects/{id}/join-requests, /{principal}/decision | GET, POST | Ask to join, decide |
/v1/projects/{id}/items, /items/{itemId} | GET, POST, PATCH | Tasks, questions, and notes. See Tasks |
/v1/projects/{id}/items/{itemId}/move, /archive, /unarchive, /restore | POST | Move a task, archive or unarchive it, or restore an earlier version |
/v1/projects/{id}/items/{itemId}/history | GET | A task's versions and change log |
/v1/projects/{id}/items/changes | GET | The change log across the project's tasks, archived ones included, newest first. field narrows it to one kind of change, and limit is 1 to 500, 100 by default |
/v1/projects/{id}/items/{itemId}/resolve | POST | Kept for older clients. Moves the task to Done |
/v1/item-types, /v1/projects/{id}/item-types, /v1/projects/{id}/statuses | GET | Every task type, and the types and statuses a project offers |
/v1/projects/{id}/goals, /goals/{goalId} | GET, POST, PATCH, DELETE | What the project is working toward. See Goals |
/v1/projects/{id}/goals/{goalId}/checkins | GET, POST | A goal's weekly check-ins. See Goal check-ins |
/v1/projects/{id}/views, /views/{viewId}, /views/order | GET, POST, PATCH, DELETE, PUT | Saved views of the project's tasks. See Saved views |
/v1/projects/{id}/changes | GET | What changed on the project's tasks, goals, views, and settings since the client last asked. See Following changes |
/v1/projects/{id}/cases, /cases/{caseId}, /cases/{caseId}/transition, /cases/{caseId}/amounts | GET, POST | A finding with one owner. See Cases |
/v1/projects/{id}/case-value | GET | Stage totals for the project's cases. Not a recovered-revenue figure |
/v1/projects/{id}/handoffs, /handoffs/{handoffId} | GET, POST | An offered package and the receiver's receipt. See Handoffs |
/v1/handoffs | GET | Every handoff the caller can read. A platform admin sees all of them |
/v1/me/handoffs | GET | Packages offered to the caller, on projects they still belong to |
/v1/projects/{id}/unblock-requests, /unblock-requests/{requestId} | GET, POST | One short question for one person. See Unblock requests |
/v1/unblock-requests | GET | Every request the caller can read. A platform admin sees all of them |
/v1/me/unblock-requests | GET | The caller's queue. include_snoozed=true keeps cards they hid |
/v1/projects/{id}/decisions, /decisions/{decisionId}, /decisions/{id}/approve | GET, POST | Proposed and approved decisions |
/v1/projects/{id}/outputs, /data-sources | GET, POST | Saved outputs and connected sources |
/v1/projects/{id}/activity, /visit | GET, POST | The feed, and marking a visit |
/v1/projects/{id}/digest | GET | What changed since your previous visit |
/v1/projects/{id}/export | GET | Everything the project holds, in one response. Editors only |
/v1/projects/{id}/pack | GET | The context pack |
/v1/projects/{id}/presentations, /presentations/{presentationId} | GET, POST | A customer presentation: outline, sources, and revisions. See Presentations |
/v1/projects/{id}/presentations/{presentationId}/build, /revisions, /resume, /share | POST | Render the outline, edit slides, retry a failed render, or pin a shared revision |
/v1/projects/{id}/presentations/{presentationId}/file | GET | The PowerPoint file for one revision |
The digest is counted from the activity feed and cached against the point in the feed where it was counted. The old service asked a model to write that sentence every time the page opened. This one does not. A model call in a page load makes the page exactly as slow and as reliable as the model behind it. Anything more interesting than a count is intelligence's job, and it is worked out once there rather than per page view.
Tasks
A task is an item with a type and a status. The product says task; the API and the database still say item.
A project's workflow decides which types and statuses it offers: product_development or
general. It is set once, when the project is created: from template_id when there is one,
otherwise from workflow, otherwise product development. Projects from before workflows are
general. The two built-in templates lead
GET /v1/project-templates, one per workflow, and every template carries its workflow; a saved
template uses product development unless it names general.
Types are company-wide: task, bug, chore, feature, question, and note. General offers
task, question, and note. GET /v1/projects/{id}/item-types lists the ones a project offers,
in the order a picker shows them, and a create or type change refuses any other. A task keeps its type if its workflow stops offering
it. An older client that sends only kind gets the project's first type of that kind. Each type
says how a client should ask for it: title_hint is an example title, and body_label and
body_hint are the label and prompt for the body ("What’s happening" for a bug). fields lists only
what the body cannot hold, each with its own placeholder. Today that is the answer to a question,
and like every field it is optional. GET /v1/item-types lists every type.
Every status belongs to one of six fixed categories: backlog, unstarted, started, review,
completed, and canceled. Completed and canceled are closed; the other four are open. Neither
template has a canceled status: work a project no longer wants is archived instead.
GET /v1/projects/{id}/statuses lists a project's statuses, each with a one-line description for
a picker to show: Triage, Backlog, Todo, In progress, In review, and Done for product development;
Todo, In progress, Waiting, and Done for general. A new task goes at the top of the project, in the
default backlog status, Triage, or in a workflow with no backlog, such as general, its default
unstarted status, Todo. status_id can name any other status the project offers, Done included.
What each type and status means, and why it exists, is on
Tasks, statuses, and goals.
{
"data": {
"type": "bug",
"title": "Save does nothing on the settings page",
"body": "Open settings, change the name, and press Save. Nothing happens, and the old name stays.",
"assignee": "[email protected]"
}
}The title is trimmed and must be 1 to 200 characters, the same as a decision title. A longer or
blank one gets a 422 that names the limit. The owner must be a member of the project. origin is
human unless an assistant wrote the task, in which case the client sends agent. A template's
starter tasks have template. Older clients send kind instead of type, and it maps to the type
of the same name.
PATCH /v1/projects/{id}/items/{itemId} makes two kinds of change, and they behave differently:
- Content is the title, body, type, fields, links, and resolution. A content change writes a
new version, and needs the
versionyou read. If someone saved a newer version first, the answer is 409 with codestale_version, and the error's first entry inerrorsholds the task as it is now invalue. - State is the status, owner, target date, and goal. A state change goes to the task's change
log and never needs a version, so moving a task is not refused because someone fixed a typo first.
An empty
assignee,target_date, orgoal_idclears it.
One PATCH can carry both, which is how the desktop app saves everything changed in a task at once.
With a new status_id, after_id and before_id place the task the way a move does, so a task
moved from its dialog lands where the same move from the list would put it.
Changing the type drops the field values the new type does not have from the current version. The earlier versions keep them. A bug needs a resolution to close. Reopening a task clears who resolved it and when; the resolution stays.
POST .../move takes a status_id, plus after_id and before_id: the tasks directly above and
below where it lands. Leave both out to keep its place. Positions are numbers with gaps of 1024,
and a move takes the midpoint. When two neighbors have no room between them, the project is
renumbered first. A resolution sent with a move needs version.
Any task can be archived, open or finished. Archiving is how a project drops work it no longer
wants: nothing is deleted, the task keeps its status, and only a view filtered to archived tasks
shows it until someone unarchives it. An archived task is read-only, with code item_archived,
until then. Archiving does not change what the task says or whether it counts as evidence.
POST .../restore with version and from_version writes a new version that copies an older one.
GET .../history returns the versions and the change log, newest first, at most 200 of each.
GET /v1/projects/{id}/items lists tasks top first. Besides state and kind, it filters on
type, category, status_id, assignee, goal_id (an id, or none), and archived
(false by default, true, or all).
Every task carries status_changed_at, when it entered the status it is in. Adding the task sets
it, and so does every change of status, whatever route makes it. Reordering, editing, archiving,
unarchiving, and restoring do not. Sorting by time_in_status uses it.
POST .../resolve is kept for older clients and moves the task to Done. It acts only on a task in
the project named in the path: a task in another project, or one that is already closed, is a 404.
An archived task gets the same 409 item_archived as any other write. A bug with no resolution gets
a 422 that asks the person to update the app, because an older client has no way to write one.
The older fields keep their values: kind is question, task, or note, state is open or
resolved, and source is human, summarizer, or template. A task an agent wrote reads
summarizer in source, and origin has the exact value.
Task, goal, check-in, and view writes publish events on the project topic. They carry ids and short values, never what somebody wrote:
| Event | When |
|---|---|
project.item_created | A task was added. Carries its type, category, and origin |
project.item_updated | A content edit. Carries the new version, the names of the fields that changed, and evidenceChanged |
project.item_status_changed | The status changed. Carries both statuses, both categories, and evidenceChanged |
project.item_resolved | The task moved from an open status to completed or canceled, by any route, or was added in a closed status |
project.item_assigned | The owner changed. The new owner gets an item_assigned notification, unless they made the change themselves |
project.item_archived, project.item_unarchived | Archived or unarchived |
project.item_restored | An earlier version was restored |
project.goal_created, project.goal_updated, project.goal_deleted | A goal was set, changed, or deleted |
project.goal_checked_in | A check-in was posted. Carries goalId, checkinId, and the status it gave the goal. The note stays off the bus |
project.view_created | A saved view was added. Carries projectId and viewId |
project.view_updated | A saved view was changed. Carries projectId and viewId |
project.view_deleted | A saved view was deleted. Carries projectId and viewId |
project.views_reordered | The views were put in a new order. Carries projectId and viewIds, every view in the new order |
The check-in and view events each have their own sentence in the audit log. A view's name and
filters stay off the bus.
Intelligence marks the project's pack stale on a check-in and ignores the view events. A check-in
also adds a goal.checked_in row to the project's activity, which the digest counts as goal
check-ins. View writes add no activity rows. The starting views a new project gets publish no
events of their own.
evidenceChanged says whether what the knowledge layer may cite from the task changed. A close or
a reopen can change it without any text changing. What counts is in
Architecture.
origin on project.item_created is who wrote the task, the same value the task's own origin
has. Events from before typed tasks do not have it.
Goals
A goal is an outcome the project is working toward, with one lead and an optional target date. No
target date means the goal is ongoing. Owners set and edit goals. The lead, who can be any member,
may change only the status and the closing note. The status is on_track, at_risk, off_track,
achieved, missed, or dropped; the last three close the goal and need a closing note saying how
it ended. A project has at most five active goals.
contributors on a goal are the owners of the unarchived tasks linked to it. Deleting a goal
unlinks its tasks, and each unlink is in that task's change log. A goal with check-ins can't be
deleted: the answer is a 422 that says to close it as dropped instead.
Every goal read also says where the goal stands against its weekly check-in, as of the read:
| Field | Meaning |
|---|---|
last_checkin_at | When the latest check-in was posted. Absent before the first |
last_checkin_status | The status the latest check-in gave the goal. Absent before the first |
checkin_state | current, due, or overdue. Absent on a closed goal |
next_checkin_on | A New York date. While the goal is due or overdue, the day its check-in was due; otherwise the next check-in day it will be due on. Absent on a closed goal |
Nothing stores the state. It is worked out from the project's checkin_weekday, when the goal was
created, and its latest check-in, each time a goal is read, by the rule on
Tasks, statuses, and goals.
checkin_weekday is a project field, 1 (Monday) to 5 (Friday), and 1 unless an owner changes it
with PATCH /v1/projects/{id}. Any other value gets a 422.
The project's free-text goals field is still in the API for older clients. Migration 0047 moved
the text in it into the project's instructions under a Goals: heading when the result fit in
20,000 characters, and left it in goals otherwise. A project created from a template gets the
template's goal text the same way, with the same limit.
Goal check-ins
A check-in is a goal's lead, or a project owner, saying in one line how the goal is going. Post one
with an Idempotency-Key:
POST /v1/projects/{id}/goals/{goalId}/checkins
{
"data": {
"status": "at_risk",
"note": "The vendor moved delivery to the 20th, so two buildings may miss the date."
}
}The answer is 201 with the check-in: id, goal_id, project_id, status_before (the goal's
status when it was posted), status, note, author, and created_at. The goal takes the
check-in's status in the same write.
- Who. The goal's lead, who can be any member, or an owner. Anyone else on the project gets a
403 with code
not_a_member. - Which goals. Active ones only. A check-in on a closed goal gets a 422. A goal is closed by updating it with a closing note, not by a check-in.
- What.
statusison_track,at_risk, oroff_track. The note is trimmed and must be one line of 1 to 500 characters. Anything else gets a 422. - Refusals. A project or goal the caller can't see, or a goal on another project, is a 404. An
archived project gets a 409 with code
project_archived.
GET .../checkins lists a goal's check-ins, newest first, for anyone who can read the project. It
pages with pagination[page] and pagination[pageSize], 25 by default and at most 100.
Check-ins are permanent. The database refuses to change or delete one, which is why a goal that has
them can't be deleted. Slack reminders for due and overdue goals are a scheduled job on
workspace-questionslack; see
Operations.
Saved views
A saved view is a shared way of looking at a project's tasks: a list or a board, with filters, a grouping, and a sort. What they are for, and the views every project starts with, are on Tasks, statuses, and goals.
| Call | Who | Contract |
|---|---|---|
GET /v1/projects/{id}/views | Viewers | Every view, in tab order |
POST /v1/projects/{id}/views | Editors | Save a view. It goes after the last tab. 201 |
PATCH /v1/projects/{id}/views/{viewId} | Editors | Edit a view. Needs the version you read |
DELETE /v1/projects/{id}/views/{viewId} | Owners | Delete a view, starting views included. 204 |
PUT /v1/projects/{id}/views/order | Owners | Put the views in a new order. Returns them in it |
Every write needs an Idempotency-Key. POST, PATCH, and PUT take a {data, why} body; DELETE takes
none. An archived project gets a 409 with code project_archived.
A view has a name, a layout of list or board, a group_by, a sort, sort_desc, and
filters. group_by is status, goal, assignee, type, or none, and status when it is
left out. A board ignores it: its columns are always statuses. sort is position, target_date,
updated, created, or time_in_status, and position when it is left out. position on the
view is its place among the tabs, smallest first. seed_key names the starting view it began as,
such as board or bugs, and stays when the view is renamed. It is absent on a view someone
added. created_by on a starting view is system.
- Limits. A project has at most 20 views. A name is trimmed and must be 1 to 60 characters, and no two views on a project share a name, ignoring case. Each limit gets a 422 that names it.
- Edits. A PATCH changes only the fields it sends, and
filters, when sent, replaces the view's filters. An empty object removes them all. If someone saved the view since you read it, the answer is 409 with codestale_version, and the error's first entry inerrorsholds the view as it is now invalue. - Reordering.
view_idsnames every view on the project once, in the new order. Leaving one out, naming one twice, or naming one from another project gets a 422. A reorder doesn't change any view'sversion. - Writes that change nothing. A PATCH that leaves the view as it was returns it without a new version, an event, or a change number. So does a PUT in the order the views are already in.
filters uses the words GET .../items uses, with a list where that takes one value. due,
none in assignees, and a list of more than one value have no GET .../items equivalent.
| Key | Matches |
|---|---|
types | Task types the project offers |
categories | backlog, unstarted, started, review, completed, canceled |
status_ids | Statuses the project offers |
assignees | Principals, or none for tasks with no owner. Never me |
goal_ids | Goals on the project, or none for tasks with no goal |
due | overdue: open tasks past their target date |
archived | true shows only archived tasks. Left out, a view shows only unarchived ones |
A task matches a view when it matches every filter that is set, and a list filter when it has any of its values. A save is strict: an unknown key or a value the project can't use gets a 422, and so does a list of more than 50 values. Repeated values are dropped. A read is lenient: a value a save would refuse, such as a status since retired or a goal since deleted, is left out, so a view as read can always be saved back.
Following changes
GET /v1/projects/{id}/changes keeps a client that shows a project's tasks current without reading
them all again. Anyone who can read the project may call it, viewers included.
Each project counts its changes. Every write to its tasks, its goals, its goals' check-ins, its
saved views, or its settings (an update, the check-in day included, archiving or unarchiving, or a
legal hold) takes the project's next number, and seq is the latest one. A view edit or reorder
that changes nothing takes no number. A client follows a project in three steps:
- Call it without
since. The answer is the currentseq, withitemsempty. - Load the tasks from
GET /v1/projects/{id}/items. - Call it with
sinceset to the lastseqit received, merge what comes back, and repeat.
A write that lands while the tasks load comes back in the first answer, so nothing slips between the two reads.
{
"data": {
"seq": 44,
"items": [ { "id": "…", "type": "bug", "title": "…", "status": { "id": "…", "name": "In review", "category": "review" }, "status_changed_at": "2026-10-05T14:02:11Z", "rank": 3072, "version": 2 } ],
"structure_changed": false,
"reset": false
}
}| Field | Meaning |
|---|---|
seq | The project's latest change number. Send it as since next time |
items | Every task that changed after since, archived ones included, once each and as it is now, in the shape GET .../items returns. Earliest change first, at most 500. Empty without since, and when reset is true |
structure_changed | Goals, check-ins, saved views, or the project's settings changed after since. Reload the goals, the views, and the project |
reset | Reload every task instead of merging items |
reset is true when every task's position was renumbered after since, when more than 500 tasks
changed, or when since is ahead of seq. The counter never goes back, so a since ahead of it
means the client's copy is not of the project as it is now. A project with nothing new costs one
row read.
A caller who is not signed in gets a 401, and one who cannot read the project gets a 404. A since
that is not a whole number gets a 422, and so does one too large for a 64-bit counter. How writes
are counted is in
Architecture.
Cases
A case is a finding someone can act on without repeating the investigation. It lives on the project
that found it. It has one owner, the evidence, the proposed correction, what still needs checking,
and a status: needs validation, accepted, in progress, verified, or dismissed. A new case starts at
needs validation. Dismissal needs a reason. Verification needs a method. Verified and dismissed
cases are not edited afterward. Editing an open case publishes project.case_updated, as opening
one and changing its status already do.
Revenue is four amounts, stored as separate rows. Amounts are minor units of the currency: 125050
USD is $1,250.50.
| Stage | Meaning |
|---|---|
| Identified | An apparent gap. It still needs an owner to confirm it. |
| Approved | The owner has confirmed the correction. Recorded only after the case is accepted. |
| Posted | The correction is in the system of record. Recorded once the work is in progress. |
| Collected | Cash observed afterward. The row says whether that cash can be tied to the correction. |
The value report does not add those figures into a recovered total. Expected value, including a recurring projection, is reported on its own and is not a receipt. Two cases that name the same subject are counted once in the money, and the report names the case that was left out. A dismissed case contributes its reason, not its amounts. Collection that has not been observed yet is pending, not zero cash and not a failure.
?actionable=true on the list returns cases that still need a decision, the work, or the check.
Handoffs
A handoff is a package one person offers to another. The next step, what still has to be true, the evidence links, and the verification date when one is set. Giving a case to someone does not open one. Changing the owner does not either. An acceptance already recorded stays on that revision.
The states are draft, offered, changes requested, accepted, and cancelled. Overdue is calculated from the due date while the package is offered or changes have been requested. It is not a state.
Only the named receiver can accept, and only while the package is offered. Acceptance means they can take the next step. It does not finish the work and it does not verify a linked case. The receipt is stored against that revision. Editing an accepted package starts a new revision and returns it to draft; the earlier receipt stays. The sender offers the new revision, and the receiver accepts it explicitly. A stale revision is rejected.
The receiver can also say something is missing, or suggest a different owner. Saying something is missing opens an unblock request for the sender and does not accept the package. Suggesting a different owner does not change who the package is offered to. The sender applies that suggestion, and the new receiver still has to accept. Cancelling is the sender's action, and it is refused once the package has been accepted.
GET /v1/me/handoffs is the packages offered to the caller. A person who has left the project
does not see them. Referring to a handoff does not grant access to the project.
GET /v1/handoffs is every handoff on a project the caller can read. A platform admin sees all
of them, without a membership row. Opening the list does not create any.
GET /v1/unblock-requests is every unblock request on a project the caller can read, with the
same admin rule. It is not the personal queue.
Offer, changes requested, and acceptance each publish an event. The notification domain delivers that to the other person. The actor is not notified of their own action.
Unblock requests
An unblock request is one question standing in front of one member: what is needed, why it matters now, which record is waiting, and how they should answer. The answer is a choice, a value, a document, a decision, or an explanation. The states are open, answered, resolved, withdrawn, and superseded.
Answering does not resolve the request, and it does not approve a decision, start a deployment, change billing, or close a linked case. The person who asked confirms the answer is enough. If it is not, they send it back: the request is open again, and the earlier answer is kept. Snooze hides the card until a time. It does not change the due date or clear what is waiting.
The personal queue is GET /v1/me/unblock-requests. It lists open requests the caller should
answer, and answered requests the caller still needs to confirm. Snoozed cards are left out unless
include_snoozed=true. Each row carries a ranking reason computed from what is waiting, the due
date, and whether the request says what happens if it slips. A person who has left the project
does not see the row. This release accepts requests a person creates. An agent source is refused.
POST /internal/jobs/unblock-reminders sends the in-app notices. Kestra calls it once a day,
behind the scheduler token. A request due today or tomorrow gets one notice for that window. Each
person with a card on the short queue also gets one daily count. The job reads the request again
before it sends, so a resolved, withdrawn, or replaced request is skipped, and a second run the
same day does not send the same notice again. The notice carries the request id, not the question.
Conversations
| Path | Method | Contract |
|---|---|---|
/v1/conversations, /v1/projects/{id}/conversations | GET, POST | Threads. Anyone signed in may start a conversation without a project. Filing one onto a project on create requires editor there. ?status=active is what agent-desktop uses so archived chats stay off the sidebar |
/v1/conversations/usage | GET | How much of the conversation record was used in a window (created or had a turn). Counted from the tables, not the event log. Platform admins only |
/v1/conversations/{id} | GET, PATCH | Read, rename, or archive. Archive is not delete: the review console still lists it |
/v1/conversations/{id}/turns | GET, POST | Append-only turns. Cursor paged |
/v1/conversations/{id}/move | POST | Reparent, files included |
/v1/conversations/{id}/sessions, /v1/sessions/{id}/heartbeat, /end | POST | Agent sessions |
/v1/conversations/{id}/suggestions, /{projectId}/resolve | GET, POST | Where an unfiled conversation might belong, proposed by intelligence |
/v1/conversations/{id}/turns/{seq}/redaction | POST | Request destruction of a turn's text |
/v1/conversations/{id}/files | GET | Documents blobs attached to this conversation, including those saved while it was unfiled |
/v1/conversations/{id}/outputs | GET, POST | Save or list versioned artifacts on the conversation's project. Inline body is capped at 256 KiB |
/v1/conversations/{id}/outputs/upload, /finalize | POST | Two-step path for larger files. Finalize rejects a sha256 mismatch |
/v1/conversations/{id}/objections | POST | The desktop agent's find_objections tool: claims in the record that argue against a plan. Needs editor on the conversation. Passed to intelligence, never answered here |
/v1/conversations/{id}/objections/feedback | POST | Rate a claim the agent raised: useful, already_knew or wrong |
/v1/conversations/{id}/related | POST | What the company already knows about the caller's own message, for the panel beside the conversation. Body is the turn_seq only; workspace reads the message |
/v1/conversations/{id}/related/actions | POST | Record opened, not_relevant or asked from the panel |
/v1/outputs/{id} | GET | One saved output. version selects a superseded row of the same slug |
/v1/projects/{id}/redactions, /v1/redactions/{id}/approve | GET, POST | Review and approve |
A conversation does not need a project. project_id is optional on create and absent from the
response until somebody files it, and only the person who started it can read an unfiled one. What
filing changes is the audience and the briefing pack, not whether the conversation becomes company
knowledge — that happens as soon as it is recorded. contributes_to_knowledge is the one way out,
set on create or later through PATCH /v1/conversations/{id}, and it is the person's decision to
make rather than the agent's. The same PATCH sets status to archived or active. Archiving
hides the thread from agent-desktop lists. It is not deleted: this console still shows it, GET by
id still works, and a turn append is refused.
Objections are the claims the desktop agent may raise when someone proposes a plan (ADR 0109).
Workspace adds what only it knows. audience is private when nobody but the caller can read the
conversation: an unfiled conversation they started, or a filed one where they are the project's
only member. Otherwise it is shared, and intelligence leaves quotes out. The agent lists the
claims it raised in an assistant turn's extra.raised_objections. Workspace leaves those out of
later calls in the conversation, and the derivation feed passes them on as raisedObjections so
extraction skips that turn. When intelligence cannot be reached the answer is degraded: true with
empty lists, never an error. The desktop side is described in
docs/intelligence/agent-desktop-objections-handoff.md.
The related-claims panel (ADR 0110) is the person's own. Workspace reads the turn named by
turn_seq, refuses anything but the caller's own message with 422, skips a message under four words
or a redacted one without asking intelligence (skipped), and answers degraded: true with empty
lists when intelligence cannot be reached. Nothing is written to the conversation, and the agent is
not given the results. The desktop side is described in
docs/intelligence/agent-desktop-related-claims-handoff.md.
Because a saved output belongs to a project and a conversation may not, POST /v1/conversations/{id}/outputs needs an explicit project_id when the conversation is unfiled. It
answers 422 rather than picking one.
Every shape this changed, field by field, is in docs/intelligence/conversations-without-a-project.md, written for somebody migrating a client.
Turns are paged with a cursor, not a page number. New turns only ever arrive at the end, so paging by number would make every poll re-read everything before it, and that prefix grows all day.
A turn has a turn_kind: utterance is speech, lookup is a read-tool stub, output marks a
saved file, and system is bookkeeping. Header and pack turn_count / utterance_count count
utterances only. Lookup stubs are not answers.
Saved outputs are versioned by slug. The same slug with the same sha256 is a no-op; a different
digest marks the previous row superseded and writes version N+1. GET lists default to
status=active. Inline content is scanned for credentials and tool logs before commit.
Notes, meetings, and threads
Anything written down or said outside an agent chat. A record belongs to a person and does not need a
project (ADR 0028). Agent Harness V1 writes
a note through save_note, which posts to /v1/notes.
| Path | Method | Contract |
|---|---|---|
/v1/notes | POST | Write a note. No project required |
/v1/notes/{evidenceId} | PATCH | Edit or archive your own note |
/v1/meetings | POST | Open a meeting, Slack thread, Plaud recording, or a recording from the companion app's microphone. source=plaud and source=phone are accepted. Repeating external_ref returns the existing record, and a new document_id on that ref repoints the meeting at the new file. Optional device_serial names the Plaud it was captured on. A phone recording leaves it off. live: true opens a recording that is about to start sending audio; live_slices on the closing call says how many slices it sent |
/v1/meetings/{evidenceId}/audio-slices | POST | Transcribe about a minute of a recording that is still being made. Raw audio in the body, index and start_ms in the query. Only the author, and only while transcription_status is live; anything else is 409 not_live. Resending an index is answered from what was stored rather than transcribed again. 503 where the deployment has no transcription configured |
/v1/plaud/user-token | POST | Mint an hour-long Plaud token for the signed-in person. The partner credential stays in this service. A successful mint is recorded as a sign-in, one per person per UTC day. 503 when Plaud is not configured |
/v1/plaud/adoption | GET | Who opened the recorder app, and how many recordings the app uploaded for them, over a half-open since/until window that defaults to the last thirty days. One row per person. A sign-in is a UTC day they asked for a token. Recordings are the app's uploads; the cloud sync is not included. Any signed-in caller. Counts only, no audio |
/v1/plaud/devices | POST | Register a recorder serial to the signed-in person. Binding finishes over Bluetooth when the app is next to the device. 503 when Plaud is not configured |
/v1/meetings/{evidenceId}/utterances | GET, POST | The lines. Cursor paged, append-only. One page is at most 200 lines. The review console follows the cursor until the record ends |
/v1/evidence/{evidenceId}/transcript | GET | The whole record as markdown: who was there, then each line as speaker and what they said. format=markdown. max_lines stops early (0 means the whole record, up to 8000 lines) and truncated names the lines left out. A note has no lines; its body is the transcript |
/v1/meetings/{evidenceId}/participants | POST | Late joiners. Speakers are also added when a line is written |
/v1/meetings/{evidenceId}/speakers/assign | POST | Name an unlabeled Zoom or Plaud speaker for this meeting |
/v1/evidence/{evidenceId} | GET | Read a record of any kind |
/v1/evidence/{evidenceId}/end-live | POST | Stops a recording that is stuck live — a recorder lost, flat, or out of range never uploads its file, and the record would otherwise sit open and keep accepting audio. The lines already written become the transcript; a recording with none ends as no_speech. Later slices are refused with 409. A record that is not live is 409. A platform admin can end any live recording. The author can end their own only while it has no lines, which is how the phone app closes a recording that captured no audio; once there are lines the author gets 403 not_a_platform_admin. Anyone else gets 404. Idempotency-Key required |
/v1/evidence/{evidenceId}/force-claim | POST | Platform admin only. Asks intelligence to queue extraction of this record with the volume and chatter gates off, and returns 202 with the job id. The claims are the model's reading of the record and appear when the intelligence worker finishes. A record with no text is refused. A non-admin gets 403 not_a_platform_admin. Idempotency-Key required |
/v1/evidence/{evidenceId} | DELETE | Platform admin only. Soft-deletes the record so it leaves lists and search. Insights that only cited this record are removed; those still supported by other evidence keep their remaining citations. The author cannot do this. A non-admin, including the author, gets 403 not_a_platform_admin. A second delete is 404. Idempotency-Key required |
/v1/evidence/{evidenceId}/summary | GET | The summary of a Zoom, phone, or Plaud meeting: overview, next_steps (each with owner and due only where the transcript says them), and chapters with start_ms. Every item lists the transcript lines it came from in source_seqs. status is none before anything has asked for a summary, then pending, running, ready, skipped (with skip_reason), or failed. can_manage says whether the caller is the host or a platform admin; recipients is filled only for them. Anyone who can read the meeting can read this. Any other kind of record is 400 |
/v1/evidence/{evidenceId}/summary | PATCH | The host or a platform admin replaces the summary with their own wording. Only a ready summary. More transcript arriving later does not overwrite an edit. Anyone else gets 403 not_the_host. Idempotency-Key required |
/v1/evidence/{evidenceId}/summary/regenerate | POST | The host or a platform admin discards any edit and queues a new summary from the transcript as it is now. Returns 202 with the pending summary. Idempotency-Key required |
/v1/evidence/{evidenceId}/summary/share | POST | The host or a platform admin emails a ready summary to people from recipients. Anyone not on that list is 422. Each send goes through communications with a delivery key of meeting, version, and recipient, so the same version reaches the same person once. Returns who was sent to and who was not. 503 when communications is not configured. Idempotency-Key required |
/v1/evidence/{evidenceId}/links | POST | File it under a project |
/v1/evidence/{evidenceId}/links/{projectId} | DELETE | Detach it |
/v1/evidence/{evidenceId}/knowledge | PUT | Contribute to the knowledge layer, or stop |
/v1/evidence/{evidenceId}/labels/{label} | PUT, DELETE | Add or remove a label, such as flow-tools. A label is a lowercase slug of up to 64 letters, digits, and hyphens. The author or a platform admin only. Needs Idempotency-Key. Emits evidence.labeled or evidence.unlabeled; adding a label the record already has emits nothing. Removing one it does not have is 404. Every record includes its labels |
/v1/evidence/{evidenceId}/utterances/{seq}/redaction | POST | Request destruction of a line |
/v1/evidence/{evidenceId}/redactions, /v1/evidence/redactions/{id}/approve | GET, POST | Review and approve |
/v1/projects/{id}/evidence | GET | What is filed under a project. Newest occurred_at first, 100 per page. Optional kind, source, and status. mine=true keeps records the caller was in, including a manual note they wrote that has no roster |
/v1/me/meetings | GET | Ids of meetings whose roster names the caller, including archived meetings. A roster row with no email still counts when that display name is already the caller's and is not also someone else's. A single word does not count. A platform admin receives their own meetings. A meeting with no identified roster is left out |
/v1/me/evidence | GET | Notes you wrote, and meetings or threads you were in. With no scope, a platform admin receives every record. scope=participant is that personal listing even for a platform admin. X-Flow-As-Member: true lists only theirs on the default list; it cannot grant admin. Same paging and filters as the project list. Opt-in scope=readable includes all sources the caller can open, including unfiled company-contributing evidence and member-project evidence. has_transcript=true on that scope leaves out Zoom meetings with no transcript lines; other sources are unchanged. has_claims=true on that scope keeps only records intelligence has drawn at least one claim from, and answers 502 when intelligence cannot be reached. Access filtering applies before pagination and counts. label keeps records carrying that label, with scope=readable only |
/v1/me/stats | GET | The overview, in one query: conversations, active Zoom meetings, Slack lines, Plaud recordings, phone recordings, emails, uploaded files, and facts. Plaud and phone recordings and emails count records, one per recording or message. A platform admin receives every conversation, every active evidence record, and every uploaded file. A member receives conversations and files in their projects plus unfiled ones they own, and evidence filed under their projects or that they were in. Facts are company-wide. X-Flow-As-Member: true counts as a member; it cannot grant admin |
/v1/me/stats/sources | GET | Activity per source since since, at most 7 days back, for the same reader as /v1/me/stats. Every source counts lines of text, where a line is the text at 100 characters wide plus each line break, so one long passage counts as several lines. A note counts the lines of its body, by when the note happened. Email, documents, coding plans, Slack, Zoom, Plaud, and Phone count the lines written on the record, by when each line was written. live on each source is the number of recordings still being made (transcription_status = live) that started in the last hour |
/v1/me/stats/agent-turns | GET | Turns written by an agent author (agent:<slug>) since since, at most 7 days back, per author, in the conversations the caller can see by the same rule as the conversation count in /v1/me/stats. The Network Observer reads it to draw agents writing to workspace |
Five things about this surface matter:
- The caller states who spoke. We never guess. Every line carries
speaker_display, and optionallyspeaker_principalandspeaker_external_id. A connector passing on Alice's Slack message records Alice, not the connector. Without that, counting independent sources would see one very talkative bot instead of a room full of people. - Adding a line is safe to repeat, in two different ways.
Idempotency-Keyhandles an HTTP retry.client_line_idhandles a webhook that gets delivered twice, which is a different request carrying the same line.external_refdoes the same job for the record itself, so a retriedPOST /v1/meetingslands on the meeting that is already open instead of splitting the transcript across two. contributes_to_knowledgeis on by default. Turning it off takes back anything already derived. On a record with no project attached, it also makes the record private again. Turning it back on isPUT /v1/evidence/{evidenceId}/knowledgewithcontributes_to_knowledge: truefor a note, orPATCH /v1/projects/{id}with the same field for a project. Intelligence starts deriving from that write; it does not infer the change from a later import of the same create key.device_serialsays which recorder captured it, and is not an identifier. It is stored upper case, bounded at 64 characters, and absent on evidence that did not come off a device.external_refremains the thing that makes a create repeatable; grouping a person's recordings by the device they came from is whatdevice_serialis for. Both the Plaud companion app and Plaud sync fill it in, so the answer does not depend on which route the audio took.transcription_statussays what is happening to a recording's words. It is separate fromstatus, which says whether the record is active or archived: a live recording is active, and so is one whose transcription failed. The values arelive(slices are arriving while the recording runs),pending(a file is stored and waiting to be read),transcribing, and the finished three —transcribed,no_speechfor audio that was read and held no words, andfailed. It is absent on anything not transcribed from audio, such as a note or a Slack thread. Before this column a meeting with no lines looked the same whether it was still being transcribed, had failed, or had nobody speaking in it.
Connectors post here with a service token plus an X-Flow-On-Behalf-Of header naming the person.
Where the Slack and Zoom connectors themselves run is not decided yet. This is the endpoint they will
call.
Facts
What a platform admin has decided is settled about the business. A Company fact: A statement about the business that a platform admin has decided is settled — a legal entity name, what a product is, which market the company sells into. It is a record in workspace's fact domain, written as markdown or uploaded as a file, readable by anyone and writable only by a platform admin. Intelligence derives claims from it the ordinary way and then confirms them instead of queueing them for review, and those claims go into every project's context pack. Distinct from an insight whose kind happens to be `fact`, which is any claim the pipeline extracted from a conversation and which still goes to a reviewer. has no project and no owner, so anyone signed in may read one and only a platform admin may write one (docs/decisions/0037-admin-curated-company-facts.md).
| Path | Method | Contract |
|---|---|---|
/v1/me | GET | The caller's principal and is_platform_admin on this request. X-Flow-As-Member: true reports false; it cannot make it true |
/v1/facts | GET | Every fact, newest change first. status narrows it, and q matches title or text |
/v1/facts/{factId} | GET | One fact, including the text a derived claim cites |
/v1/facts | POST | Record one, from body markdown or from an uploaded file |
/v1/facts/upload-url | POST | Mint the write URL for a source document, via documents |
/v1/facts/{factId} | POST | Rewrite the title, the text, or the file |
/v1/facts/{factId}/supersede | POST | Point this fact at the newer one that replaces it |
/v1/facts/{factId}/archive | POST | Withdraw it with no replacement |
Three things about this surface matter:
- A file's markdown is extracted before the fact exists. The create call records the document,
reads the bytes back, and converts Word, PDF, Markdown, or plain text to markdown, all before the
transaction opens, so a fact is never a row waiting for its own content. A
.mdor.txtupload does not need any of that: read it in the client and send it asbody. - Nothing is ever deleted. Superseding and archiving leave the row readable, because intelligence
has claims citing the text and needs it to stay there while it withdraws them. Only an active fact
accepts a write; a withdrawn one answers
409with codefact_not_active. - A write refused for want of admin rights is a
403, with codenot_a_platform_admin, unlike the404the stewardship routes use. A fact is company-readable, so hiding the route would tell the caller nothing new.
Audit log
| Path | Method | Contract |
|---|---|---|
/v1/audit | GET | Newest first. Platform admins only. Filter by domain, eventType, actor, or entityId, all exact. q is free text, matching the summary, the actor, or the event type |
/v1/audit/{id} | GET | One row. Platform admins only |
Each row is one sentence about a workspace event that already happened
(ADR 0043). The stored payload is identifiers,
not titles or body text. A caller who is not a platform admin gets 403 with code
not_a_platform_admin.
workspaceAuditListener writes the rows. The HTTP surface only reads them.
Files, notifications, stewardship, search
| Path | Method | Contract |
|---|---|---|
/v1/projects/{id}/files, /files/upload-url | GET, POST | Attachments, via documents. conversation_id marks a chat attachment |
/v1/files, /v1/files/upload-url | POST | Upload with no project. conversation_id attaches it to an unfiled chat. purpose=live_recording on the upload URL keeps it valid for six hours |
/v1/files/upload-url/renew | POST | A fresh write URL for a blob a live recording is already writing to, for a recording that outlasts its first URL. Only the principal that minted the blob may renew it, and only while no document has been recorded against it. The blob name does not change, so blocks already written stay where they are |
/v1/conversations/{id}/files | GET | Files on one conversation, filed or unfiled |
/v1/me/files | GET | The caller's unfiled files. A platform admin receives every unfiled file. X-Flow-As-Member: true lists only theirs; it cannot grant admin. scope=readable is every file the caller may open — files in their projects, plus unfiled files they uploaded — newest first, one page at a time. A platform admin receives every project and unfiled file on that scope. q matches filename or uploader |
/v1/files/{documentId}, /unlink | GET, POST | GET includes a short-lived download_url; unlink drops the claim |
A file needs no project. Without one it is recorded against the principal who uploaded it, and
project_id is absent from the response while owner is set. A platform admin's
GET /v1/me/files, without scope, is every such file, the same expansion unfiled conversations already have.
scope=readable is the review list instead: one page of every file the caller may open, newest first. Pass conversation_id on
POST /v1/files to attach that blob to an unfiled chat; filing the conversation later files the
attachment onto the project. A conversation that already has a project uses
POST /v1/projects/{id}/files with kind=chat-attachment instead. Unlink applies only to a filed
file: an unfiled one has nothing to be detached from. List responses carry no download URL; GET
/v1/files/{documentId} mints one per request, on the storage account's TTL.
Every file workspace records stores contributes_to_knowledge as document metadata, set to true.
Ingestion into the knowledge layer was already the default for a document that says nothing, so this
changes no behavior — it makes the stored record state the decision rather than leave it inferred.
An uploaded file (kind uploaded, with no conversation) is filed as evidence when it is recorded,
and linked to the project when it has one. A chat attachment and a saved output are not filed this
way. A recording is left for the meeting that claims it.
The document listener still reads the bytes and adds the extracted text when the file has any. It reads files up to 100 MiB. A larger file is stored and left without extracted text: the file and its parsed form are both held in memory, and a spreadsheet or a PDF grows several times over while it is read. A scanned PDF stays on the evidence list without lines. Word, PDF, Markdown, and plain text are converted to markdown for that text, which is also what a fact's body holds when the fact came from a file.
| Path | Method | Contract |
|---|---|---|
/v1/me/notifications, /{id}/read, /read-all | GET, POST | The caller's inbox |
/v1/me/notification-prefs | GET, POST | Per-kind, per-channel switches |
/v1/me/conversations | GET | The caller's unfiled conversations, newest activity first. A platform admin receives every unfiled conversation. X-Flow-As-Member: true lists only their own; it cannot grant admin. scope=readable is every conversation the caller may open, still newest first, one page at a time. q matches title, author, or agent on that list |
/v1/me/scratchpad | GET | The caller's scratchpad project, kept for the outputs and files already in it |
/v1/projects/{id}/legal-hold, /break-glass, /transfer-ownership | POST | Stewardship. Platform admins only |
/v1/search/evidence | GET | Full-text over turns, project artifacts, records, and utterances. has_transcript=true leaves Zoom meetings with no transcript lines out of the record results; other sources and utterance matches are unchanged. has_claims=true keeps only records that have a claim; utterance matches are unchanged |
Search returns four lists — turns, artifacts, records, utterances — and the kind parameter
narrows it to one. A relative day (yesterday, today, last night, last week, this week)
or a YYYY-MM-DD date limits notes and meetings to that window. It is not a word the text has to
contain. Two or more personal names in that kind of question match the people who were in the
meeting, including when the transcript never says their names. The day is read in America/New_York,
with several hours of slack so a meeting held in another timezone still falls on the day the asker
meant. A question that names two people and a day does not search agent chats: the chat in
which the question was asked contains those words, and it is not the meeting. A meeting hit
names who was in the room, including people who did not say their own names. All four lists
check the caller's permission inside the SQL, not after ranking.
Filtering afterwards would let a result the caller may not see take up a place in the top ten. The
gap where it was removed would then tell them something is there.
Enveloped All /v1/* JSON uses the Response envelope: The template service wraps every JSON response in {header, payload}. A success payload is {data} (a record or a list) plus optional meta.pagination; accounting still returns bare bodies and RFC 9457 errors until the divergence is resolved., and
every mutation is a Command: An HTTP POST that changes state, recorded with an Idempotency-Key so retries do not double-apply; not CQRS or a command bus.: POST, an Idempotency-Key: A caller-supplied header, stable across retries, that lets command.Execute claim the operation once and replay the same response on duplicate requests., and a body of
{data, why}.
Presentations
A presentation is a customer deck stored on the project it belongs to. Drafting an outline does not
call a model. The caller supplies the audience and the source material. Workspace selects what is
allowed to appear, fits it to the pinned Flow Decks template (customer-brief, version
2026-10-01), and renders an editable PowerPoint file.
The stages on a revision are preparing sources, drafting outline, building slides, checking, ready,
or failed. A failed render keeps the slide specification. POST .../resume renders that
specification again. It does not draft a new story.
Internal material can be stored with the sources so the outline knows it was considered. Its text is removed from the HTTP response and is not copied onto slides or into speaker notes. A claim with no approved result is left blank and named in the review notes. The deck does not invent one.
POST .../share records the current revision number. A later edit inserts a new revision and
leaves the shared one readable with ?revision= or ?shared=true. Updating numbers returns the
old value, the new value, the period, the observation date, and the slides whose wording may also
need to change. Locked slides are copied through unchanged.
The file download is rendered from the saved specification. Text, tables, and charts are editable objects in the PowerPoint file. The file follows the Flow Decks slide gallery: a midnight cover set in Roughwell, content slides with a sunlight label above a Roughwell title, and Poppins for body text. Generation 1970 is used for the small label. Those font files are embedded in the PowerPoint file.
The context pack
GET /v1/projects/{id}/pack returns the project's own state and the knowledge that bears on it:
{
"project": { "id": "…", "name": "Q3 pricing", "goals": "", "instructions": "…" },
"open_items": [ { "kind": "task", "type": "bug", "title": "…", "status": "In progress", "category": "started", "age_days": 4.2, "days_in_status": 1.5 } ],
"goals": [ { "id": "…", "outcome": "New prices live in every building", "lead": "…", "target_date": "2026-12-31", "status": "on_track", "checkin_state": "current", "last_checkin_on": "2026-10-05" } ],
"decisions": [ { "title": "…", "state": "approved", "decided_at": "…" } ],
"saved_outputs": [ { "output_id": "…", "title": "…", "filename": "spec.md", "media_type": "text/markdown", "version": 1, "bytes": 8421, "url": "/v1/outputs/…" } ],
"conversations": [ { "title": "…", "turn_count": 12 } ],
"knowledge": {
"insights": [ … ],
"open_questions": [ … ],
"related_work": [ … ],
"who_knows": [ … ],
"company_facts": [ … ],
"degraded": false
},
"generated_at": "2026-08-28T14:02:00Z"
}Four things about it matter:
- Open items are the first 25 open, unarchived tasks: backlog first, then not started, started,
and in review, each in rank order. Each one has its type, status, category, target date, and its
goal, plus
age_dayssince it was added anddays_in_statussince it entered its status. Bodies are cut at 400 characters.goalslists the active goals, each with itscheckin_stateandlast_checkin_on, the New York date of its latest check-in, absent before the first. A check-in's note is a person's own words and stays out of the pack. - Only approved decisions appear. A proposed decision is still an argument in progress. Handing one to an agent as if it were settled is how the pack starts producing confident wrong answers.
knowledge.degradedtells you the difference between silence and failure. Check it. Do not read an emptyinsightslist as "nothing is known".knowledge.company_factsis the same list in every project's pack. It is what a platform admin has settled about the business, and it is deliberately not filtered by how well it matches the project: the company's legal name has nothing topically in common with a pricing project, and that is exactly the project that must not get it wrong. It is also filled whenknowledge.rebuildingis true, because this project's inputs changing says nothing about whether the company still has the name it has.
The pack is pasted onto the front of a prompt, so the size limits on each section are a budget. Every character spent on background is a character the conversation cannot use. The limits are in services/workspace/internal/handlers/pack.go.
Person context
GET /v1/context/person returns what Flow knows about the caller's own work. It is the same
answer for a scheduled report and for a Desktop turn, including a turn in an unfiled conversation.
Nothing in the request names a person. The recipient is the signed-in principal. A scheduled job
calls with the service token and X-Flow-On-Behalf-Of: <email> and gets that person's context.
| Query | Meaning |
|---|---|
mode | brief returns the whole picture for a report. chat (the default) returns what bears on query |
query | Chat mode only. Responsibilities, threads, interests and commitments are ranked against it and capped |
conversation_id | The conversation the turn belongs to. Works for an unfiled conversation. A conversation you cannot read is a 404 |
project_id | Adds the project's pack under project, including the knowledge text. A project you cannot read is a 404 |
since | RFC 3339. Drops older items that are not open obligations |
{
"recipient": { "principal_id": "[email protected]", "email": "[email protected]", "name": "Ann Lee", "person_id": "…", "identity_match": "email", "ambiguous": false },
"profile": {
"title": "Leasing Agent", "title_source": "directory", "team": "", "function": "Leasing",
"manager": { "name": "…", "email": "…", "title": "…" }, "reports": [],
"responsibilities": [ { "text": "…", "confidence": "inferred", "source_ids": [ "evidence:…" ] } ],
"disputed": false, "version": 3
},
"threads": [ {
"id": "…", "topic": "Wynwood renewals", "entities": [ { "id": "…", "name": "Renewals" } ],
"relationship": "assigned_action", "goal": "…", "milestone": "Response due 2026-10-09", "state": "open",
"last_supported_at": "2026-10-05T14:00:00Z", "confidence": "corroborated",
"sources": [ { "id": "unblock:…", "kind": "project_item", "occurred_at": "…", "ref": "…" } ]
} ],
"interests": [ { "topic": "…", "kind": "asked", "asked_at": "…", "source_ids": [ "turn:…:4" ] } ],
"commitments": [ { "id": "…", "text": "…", "status": "open", "due": null, "due_source_id": "", "source_ids": [ "evidence:…" ] } ],
"preferences": [ { "text": "Keep answers short", "scope": "output" } ],
"coverage": { "sources_checked": [ "conversations", "meetings", "project_items" ], "gaps": [ "…" ], "window_from": "…", "window_to": "…", "sparse": false },
"profile_version": 3,
"generated_at": "…"
}Things to know when reading it:
dueis present only when a source states a date.due_source_idsays which source. A commitment with no date hasdue: null. Do not invent one.- Open commitments are kept at any age. A commitment stays until it is done or dropped.
relationshipsays how the person is connected to the topic. Being asked to do something (assigned_action) is a stronger signal than being named in passing (merely_mentioned). Belonging to a project says nothing by itself. A manager sees their reports' blockers asmanages_owner.coverage.gapslists what could not be read. If the people directory is down, the stored role, manager and reports are returned and a gap says so. Ifsparseis true, no conversations or meetings were found. Role and assignments are still shown. That is not evidence the person has no work.ambiguous: truemeans the directory could not tell which person this is. No title or manager is asserted.- With personalization off, no private records are returned. The formal role from the directory remains.
Three routes maintain it. All of them act on the caller's own context:
| Route | What it does |
|---|---|
PUT /v1/context/person/profile | The person's own correction of title, team, function, responsibilities and preferences, and the personalization switch. Needs an Idempotency-Key. A corrected field is not overwritten by the directory. A title that differs from the directory sets disputed |
POST /v1/context/person/refresh | Rebuilds the caller's derived rows now. ?full=true re-reads the whole window. Safe to repeat |
POST /internal/jobs/person-context-refresh | The scheduled job. Refreshes everyone with an eligible identity. Behind the scheduler secret, outside the OpenAPI document |
What Flow knows about you
GET /v1/me/known is the agent-desktop "You" page (ADR 0111, ADR 0115). It is the caller's own,
with no person parameter. What the caller is working on comes from the same person context and
leaves out anything drawn only from places the caller was not present: a meeting they were not on the roster of and did not speak in, a
thread they did not post in, or a conversation they did not write in. A project task, case, handoff
or unblock request always counts, because it is addressed to them. An item keeps only the sources
that pass. This removes the mention threads person context builds from meetings linked to the
caller's projects; the agent still reads those through /v1/context/person.
The answer has working_on (threads with at least one meeting, thread or conversation source),
assigned (threads drawn only from project rows), commitments, interests, preferences, the
profile and accepted linked accounts, and claims: published claims about the caller from
intelligence, each with only the quotes from records the caller was present for, read from
workspace's own records. sources names every source the items carry, with its kind, title, date and
the record or conversation it opens. When intelligence cannot be reached, claims_available is false
and the rest is still served.
What others said about the caller (ADR 0115) is not limited to where they were. said_about_you
is lines by other people naming them by full name or email in the past 30 days, newest first, at
most 30, and claim quotes include those from records the caller was not in. Both come only from
records the caller may open as a member, never with a platform admin's wider reach, and each is
marked you_were_there.
Filtering original-evidence search
The evidence search accepts optional source and RFC3339 since filters when
kind=records or kind=utterances. Other kinds reject these filters with 400.
Connector and date predicates run before ranking and limiting, together with the
existing caller access checks. For records, since uses the original record
occurrence time; for utterances it uses the utterance occurrence time, so a recent
reply in an older Slack thread remains searchable. Ingestion time is not freshness.
Errors
Errors use the platform's standard shape, with a code when a client needs to react differently.
404 covers both "no such project" and "you are not on it". Returning 403 for someone else's
project would confirm that the project exists.
Not in the spec
/internal/jobs/* and /internal/derivation/* are authenticated by shared secrets and excluded
from the OpenAPI document on purpose. See
Architecture.
Tasks, statuses, and goals
What every task type, status, and goal status means, why each one exists, how work moves through the stages, saved views, weekly goal check-ins, and the rules that keep project work honest.
Operations
Deploying workspace — twelve Helm releases from one image, the bus topology it owns, its scheduled jobs, and the secrets it needs before a first deploy.