Flow
ServicesWorkspace Service

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

PathMethodNotes
/GETService discovery (enveloped)
/api-docsGETSwagger UI when SWAGGER_ON=true
/openapi.yaml, /openapi.jsonGETSpec generated from the Go handler types
/healthzGETLiveness
/readyzGETReadiness (Postgres ping)

Projects

PathMethodContract
/v1/projectsGET, POSTList 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-templatesGETDiscovery surfaces
/v1/project-templatesPOSTSave a template, with the workflow its projects get. See Tasks
/v1/projects/{id}GET, PATCHRead and update. Owners only for the update, which is also where the check-in day is set
/v1/projects/{id}/statusPOSTArchive, restore, withdraw from knowledge
/v1/projects/{id}/members, /members/{principal}GET, POSTMembership
/v1/projects/{id}/join-requests, /{principal}/decisionGET, POSTAsk to join, decide
/v1/projects/{id}/items, /items/{itemId}GET, POST, PATCHTasks, questions, and notes. See Tasks
/v1/projects/{id}/items/{itemId}/move, /archive, /unarchive, /restorePOSTMove a task, archive or unarchive it, or restore an earlier version
/v1/projects/{id}/items/{itemId}/historyGETA task's versions and change log
/v1/projects/{id}/items/changesGETThe 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}/resolvePOSTKept for older clients. Moves the task to Done
/v1/item-types, /v1/projects/{id}/item-types, /v1/projects/{id}/statusesGETEvery task type, and the types and statuses a project offers
/v1/projects/{id}/goals, /goals/{goalId}GET, POST, PATCH, DELETEWhat the project is working toward. See Goals
/v1/projects/{id}/goals/{goalId}/checkinsGET, POSTA goal's weekly check-ins. See Goal check-ins
/v1/projects/{id}/views, /views/{viewId}, /views/orderGET, POST, PATCH, DELETE, PUTSaved views of the project's tasks. See Saved views
/v1/projects/{id}/changesGETWhat 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}/amountsGET, POSTA finding with one owner. See Cases
/v1/projects/{id}/case-valueGETStage totals for the project's cases. Not a recovered-revenue figure
/v1/projects/{id}/handoffs, /handoffs/{handoffId}GET, POSTAn offered package and the receiver's receipt. See Handoffs
/v1/handoffsGETEvery handoff the caller can read. A platform admin sees all of them
/v1/me/handoffsGETPackages offered to the caller, on projects they still belong to
/v1/projects/{id}/unblock-requests, /unblock-requests/{requestId}GET, POSTOne short question for one person. See Unblock requests
/v1/unblock-requestsGETEvery request the caller can read. A platform admin sees all of them
/v1/me/unblock-requestsGETThe caller's queue. include_snoozed=true keeps cards they hid
/v1/projects/{id}/decisions, /decisions/{decisionId}, /decisions/{id}/approveGET, POSTProposed and approved decisions
/v1/projects/{id}/outputs, /data-sourcesGET, POSTSaved outputs and connected sources
/v1/projects/{id}/activity, /visitGET, POSTThe feed, and marking a visit
/v1/projects/{id}/digestGETWhat changed since your previous visit
/v1/projects/{id}/exportGETEverything the project holds, in one response. Editors only
/v1/projects/{id}/packGETThe context pack
/v1/projects/{id}/presentations, /presentations/{presentationId}GET, POSTA customer presentation: outline, sources, and revisions. See Presentations
/v1/projects/{id}/presentations/{presentationId}/build, /revisions, /resume, /sharePOSTRender the outline, edit slides, retry a failed render, or pin a shared revision
/v1/projects/{id}/presentations/{presentationId}/fileGETThe 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 version you read. If someone saved a newer version first, the answer is 409 with code stale_version, and the error's first entry in errors holds the task as it is now in value.
  • 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, or goal_id clears 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:

EventWhen
project.item_createdA task was added. Carries its type, category, and origin
project.item_updatedA content edit. Carries the new version, the names of the fields that changed, and evidenceChanged
project.item_status_changedThe status changed. Carries both statuses, both categories, and evidenceChanged
project.item_resolvedThe task moved from an open status to completed or canceled, by any route, or was added in a closed status
project.item_assignedThe owner changed. The new owner gets an item_assigned notification, unless they made the change themselves
project.item_archived, project.item_unarchivedArchived or unarchived
project.item_restoredAn earlier version was restored
project.goal_created, project.goal_updated, project.goal_deletedA goal was set, changed, or deleted
project.goal_checked_inA check-in was posted. Carries goalId, checkinId, and the status it gave the goal. The note stays off the bus
project.view_createdA saved view was added. Carries projectId and viewId
project.view_updatedA saved view was changed. Carries projectId and viewId
project.view_deletedA saved view was deleted. Carries projectId and viewId
project.views_reorderedThe 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:

FieldMeaning
last_checkin_atWhen the latest check-in was posted. Absent before the first
last_checkin_statusThe status the latest check-in gave the goal. Absent before the first
checkin_statecurrent, due, or overdue. Absent on a closed goal
next_checkin_onA 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. status is on_track, at_risk, or off_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.

CallWhoContract
GET /v1/projects/{id}/viewsViewersEvery view, in tab order
POST /v1/projects/{id}/viewsEditorsSave a view. It goes after the last tab. 201
PATCH /v1/projects/{id}/views/{viewId}EditorsEdit a view. Needs the version you read
DELETE /v1/projects/{id}/views/{viewId}OwnersDelete a view, starting views included. 204
PUT /v1/projects/{id}/views/orderOwnersPut 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 code stale_version, and the error's first entry in errors holds the view as it is now in value.
  • Reordering. view_ids names 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's version.
  • 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.

KeyMatches
typesTask types the project offers
categoriesbacklog, unstarted, started, review, completed, canceled
status_idsStatuses the project offers
assigneesPrincipals, or none for tasks with no owner. Never me
goal_idsGoals on the project, or none for tasks with no goal
dueoverdue: open tasks past their target date
archivedtrue 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:

  1. Call it without since. The answer is the current seq, with items empty.
  2. Load the tasks from GET /v1/projects/{id}/items.
  3. Call it with since set to the last seq it 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
  }
}
FieldMeaning
seqThe project's latest change number. Send it as since next time
itemsEvery 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_changedGoals, check-ins, saved views, or the project's settings changed after since. Reload the goals, the views, and the project
resetReload 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.

StageMeaning
IdentifiedAn apparent gap. It still needs an owner to confirm it.
ApprovedThe owner has confirmed the correction. Recorded only after the case is accepted.
PostedThe correction is in the system of record. Recorded once the work is in progress.
CollectedCash 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

PathMethodContract
/v1/conversations, /v1/projects/{id}/conversationsGET, POSTThreads. 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/usageGETHow 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, PATCHRead, rename, or archive. Archive is not delete: the review console still lists it
/v1/conversations/{id}/turnsGET, POSTAppend-only turns. Cursor paged
/v1/conversations/{id}/movePOSTReparent, files included
/v1/conversations/{id}/sessions, /v1/sessions/{id}/heartbeat, /endPOSTAgent sessions
/v1/conversations/{id}/suggestions, /{projectId}/resolveGET, POSTWhere an unfiled conversation might belong, proposed by intelligence
/v1/conversations/{id}/turns/{seq}/redactionPOSTRequest destruction of a turn's text
/v1/conversations/{id}/filesGETDocuments blobs attached to this conversation, including those saved while it was unfiled
/v1/conversations/{id}/outputsGET, POSTSave or list versioned artifacts on the conversation's project. Inline body is capped at 256 KiB
/v1/conversations/{id}/outputs/upload, /finalizePOSTTwo-step path for larger files. Finalize rejects a sha256 mismatch
/v1/conversations/{id}/objectionsPOSTThe 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/feedbackPOSTRate a claim the agent raised: useful, already_knew or wrong
/v1/conversations/{id}/relatedPOSTWhat 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/actionsPOSTRecord opened, not_relevant or asked from the panel
/v1/outputs/{id}GETOne saved output. version selects a superseded row of the same slug
/v1/projects/{id}/redactions, /v1/redactions/{id}/approveGET, POSTReview 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.

PathMethodContract
/v1/notesPOSTWrite a note. No project required
/v1/notes/{evidenceId}PATCHEdit or archive your own note
/v1/meetingsPOSTOpen 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-slicesPOSTTranscribe 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-tokenPOSTMint 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/adoptionGETWho 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/devicesPOSTRegister 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}/utterancesGET, POSTThe 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}/transcriptGETThe 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}/participantsPOSTLate joiners. Speakers are also added when a line is written
/v1/meetings/{evidenceId}/speakers/assignPOSTName an unlabeled Zoom or Plaud speaker for this meeting
/v1/evidence/{evidenceId}GETRead a record of any kind
/v1/evidence/{evidenceId}/end-livePOSTStops 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-claimPOSTPlatform 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}DELETEPlatform 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}/summaryGETThe 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}/summaryPATCHThe 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/regeneratePOSTThe 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/sharePOSTThe 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}/linksPOSTFile it under a project
/v1/evidence/{evidenceId}/links/{projectId}DELETEDetach it
/v1/evidence/{evidenceId}/knowledgePUTContribute to the knowledge layer, or stop
/v1/evidence/{evidenceId}/labels/{label}PUT, DELETEAdd 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}/redactionPOSTRequest destruction of a line
/v1/evidence/{evidenceId}/redactions, /v1/evidence/redactions/{id}/approveGET, POSTReview and approve
/v1/projects/{id}/evidenceGETWhat 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/meetingsGETIds 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/evidenceGETNotes 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/statsGETThe 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/sourcesGETActivity 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-turnsGETTurns 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 optionally speaker_principal and speaker_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-Key handles an HTTP retry. client_line_id handles a webhook that gets delivered twice, which is a different request carrying the same line. external_ref does the same job for the record itself, so a retried POST /v1/meetings lands on the meeting that is already open instead of splitting the transcript across two.
  • contributes_to_knowledge is 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 is PUT /v1/evidence/{evidenceId}/knowledge with contributes_to_knowledge: true for a note, or PATCH /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_serial says 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_ref remains the thing that makes a create repeatable; grouping a person's recordings by the device they came from is what device_serial is 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_status says what is happening to a recording's words. It is separate from status, which says whether the record is active or archived: a live recording is active, and so is one whose transcription failed. The values are live (slices are arriving while the recording runs), pending (a file is stored and waiting to be read), transcribing, and the finished three — transcribed, no_speech for audio that was read and held no words, and failed. 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).

PathMethodContract
/v1/meGETThe caller's principal and is_platform_admin on this request. X-Flow-As-Member: true reports false; it cannot make it true
/v1/factsGETEvery fact, newest change first. status narrows it, and q matches title or text
/v1/facts/{factId}GETOne fact, including the text a derived claim cites
/v1/factsPOSTRecord one, from body markdown or from an uploaded file
/v1/facts/upload-urlPOSTMint the write URL for a source document, via documents
/v1/facts/{factId}POSTRewrite the title, the text, or the file
/v1/facts/{factId}/supersedePOSTPoint this fact at the newer one that replaces it
/v1/facts/{factId}/archivePOSTWithdraw 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 .md or .txt upload does not need any of that: read it in the client and send it as body.
  • 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 409 with code fact_not_active.
  • A write refused for want of admin rights is a 403, with code not_a_platform_admin, unlike the 404 the stewardship routes use. A fact is company-readable, so hiding the route would tell the caller nothing new.

Audit log

PathMethodContract
/v1/auditGETNewest 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}GETOne 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.

PathMethodContract
/v1/projects/{id}/files, /files/upload-urlGET, POSTAttachments, via documents. conversation_id marks a chat attachment
/v1/files, /v1/files/upload-urlPOSTUpload 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/renewPOSTA 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}/filesGETFiles on one conversation, filed or unfiled
/v1/me/filesGETThe 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}, /unlinkGET, POSTGET 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.

PathMethodContract
/v1/me/notifications, /{id}/read, /read-allGET, POSTThe caller's inbox
/v1/me/notification-prefsGET, POSTPer-kind, per-channel switches
/v1/me/conversationsGETThe 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/scratchpadGETThe caller's scratchpad project, kept for the outputs and files already in it
/v1/projects/{id}/legal-hold, /break-glass, /transfer-ownershipPOSTStewardship. Platform admins only
/v1/search/evidenceGETFull-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_days since it was added and days_in_status since it entered its status. Bodies are cut at 400 characters. goals lists the active goals, each with its checkin_state and last_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.degraded tells you the difference between silence and failure. Check it. Do not read an empty insights list as "nothing is known".
  • knowledge.company_facts is 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 when knowledge.rebuilding is 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.

QueryMeaning
modebrief returns the whole picture for a report. chat (the default) returns what bears on query
queryChat mode only. Responsibilities, threads, interests and commitments are ranked against it and capped
conversation_idThe conversation the turn belongs to. Works for an unfiled conversation. A conversation you cannot read is a 404
project_idAdds the project's pack under project, including the knowledge text. A project you cannot read is a 404
sinceRFC 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:

  • due is present only when a source states a date. due_source_id says which source. A commitment with no date has due: null. Do not invent one.
  • Open commitments are kept at any age. A commitment stays until it is done or dropped.
  • relationship says 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 as manages_owner.
  • coverage.gaps lists what could not be read. If the people directory is down, the stored role, manager and reports are returned and a gap says so. If sparse is true, no conversations or meetings were found. Role and assignments are still shown. That is not evidence the person has no work.
  • ambiguous: true means 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:

RouteWhat it does
PUT /v1/context/person/profileThe 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/refreshRebuilds the caller's derived rows now. ?full=true re-reads the whole window. Safe to repeat
POST /internal/jobs/person-context-refreshThe 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.

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.

On this page