docs: labels in the manual and the SDK guide, and one SDK release (#1637) - #1849
Merged
Merged
Conversation
jhgaylor
force-pushed
the
stack/1637-console-chips
branch
from
September 10, 2026 13:45
2165dc2 to
b02ec62
Compare
jhgaylor
force-pushed
the
stack/1637-release
branch
from
September 10, 2026 13:45
a1f15d4 to
269e0de
Compare
lex00
approved these changes
Sep 10, 2026
This was referenced Sep 10, 2026
jhgaylor
force-pushed
the
stack/1637-console-chips
branch
from
September 11, 2026 02:27
b02ec62 to
b846029
Compare
jhgaylor
force-pushed
the
stack/1637-release
branch
from
September 11, 2026 02:27
269e0de to
7c79d40
Compare
jhgaylor
force-pushed
the
stack/1637-console-chips
branch
from
September 11, 2026 02:44
b846029 to
6d878df
Compare
jhgaylor
force-pushed
the
stack/1637-release
branch
from
September 11, 2026 02:44
7c79d40 to
502d5a5
Compare
jhgaylor
force-pushed
the
stack/1637-console-chips
branch
from
September 11, 2026 02:53
6d878df to
eed5631
Compare
jhgaylor
force-pushed
the
stack/1637-release
branch
from
September 11, 2026 02:53
502d5a5 to
893e5c3
Compare
…mbined (#1637) `GET /api/conversations?label=env:prod&label=drift:true` keeps the conversations that carry both. The query is jsonb containment, which the GIN index the column already has serves; a row with more labels than the filter names still matches. Each value splits on its **first** colon only, so `label=path:a:b` filters `path` for `a:b`, and a value with no colon or an empty key is a 400 `invalid_label_filter` rather than a silent match-all. The parameter is declared honestly as an array (`style: form, explode: true`), which needed one piece of plumbing to be possible at all — `FountainWeb.Plugs.RepeatedQueryParam`. Two things sit in the way of a repeated query key here: - `Plug.Conn.Query` collapses a repeated key to its **last** value, so `conn.params["label"]` would be `"drift:true"` and the first filter would be gone; - `OpenApiSpex.CastParameters` reads query parameters straight out of Plug and implements only the `explode: false` comma-joined form itself, so a parameter declared as an array would be handed that string and refuse it as "not an array" — turning an ordinary `?label=env:prod` into a 422. So the plug reads the raw query string, collects every occurrence, and writes the list back onto both `query_params` and `params` before the cast runs. `label[]=` is collected too, for a client whose HTTP layer only builds arrays that way. `FountainWeb.LabelFilter` is the parsing, in one place, because the team route takes the same parameter next and a second copy is exactly how the two would drift. The TypeScript SDK gains `conversations({labels})`, and its query builder now expands an array into a repeated key instead of joining it with commas — joining would have sent one filter the server cannot parse. Fourth of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
…1637) `POST /api/team/:agent_id/messages` takes `labels`, merged onto the conversation the message lands on — the teammate's current one, or the fresh one a thread past resuming is replaced by, in which case they ride in the create attrs so the conversation is not labelled twice. **Merged before the prompt is queued.** A label the limits refuse therefore means nothing happened at all, rather than "the message went and the labels did not". Through `Conversations.set_conversation_labels/4` and not the writer beneath it, because this route accepts a sandbox's own `sprite` token and the teammate's conversation is somebody else's conversation as far as that token is concerned. The refusal is the same 403 `PATCH .../labels` gives. `GET /api/team/:agent_id/conversations` takes the same repeatable, AND-combined `label` parameter, over the same `FountainWeb.LabelFilter` and the same containment fragment, so the two lists cannot disagree about what `?label=env:prod` means. Fifth of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
A deterministic run knows what it found by the time the turn ends. Making it call `PATCH /api/conversations/:id/labels` to say so means a second credential, a second round trip and a conversation id it has to have been told. It is already holding an ACP session with Fountain on the other end. So `session/update` with `sessionUpdate: "_fountain/labels"` merges labels onto the conversation the session belongs to. ACP keeps a leading `_` for extensions, and `Managoat.ACP.Peer` forwards `session/update` and drops every other notification method, which is why this rides there rather than on a method of its own. It is a control message and not something the agent said: it opens no turn, re-arms no quiet timer and never reaches the transcript — the third exception in `TurnMachine.handle/3` beside a replayed line and session metadata. A cheap substring test runs before the decode, because this sees every protocol line of every turn and all but a handful of them are agent output. **Nothing a stamp contains can take the turn down.** `Labels._unsafe_stamp/2` logs and drops a stamp the limits refuse, and rescues anything that raises on its way to the database. The run is mid-turn doing real work; losing it because a value was 300 bytes long, or held a byte `jsonb` will not store, would be the worse outcome by a distance. That is also why the NUL check exists at all — Postgres refuses a NUL inside `jsonb`, and without the check the write would come back as a raised `Postgrex.Error` travelling up through the turn machine. Unscoped, and legitimately so: the id is the one this machine's own `ConversationServer` was started with, so it cannot name another conversation, of this tenant or any other. Recorded as `sprite` (ADR 0013). Sixth of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
…1637) A receiver that routes on `env=prod` or files a `drift=true` run should not have to call back for the labels it was already told about. `data.labels` rides on every `conversation.*` payload, read at dispatch time, and is an object — empty rather than null — when the conversation carries none. This stays inside the payload's standing rule, which is ids, a stage, a status and a duration and nothing else. Labels are ids and facts a caller put there itself, never content. The manual says so plainly, because the agent in the sandbox can stamp them too: a label is data your own run wrote, not a value Fountain derived. Seventh of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
) The dashboard's recent-conversations list and the admin user page render a conversation's labels as small `key=value` chips, sorted by key so a row reads the same on every render, and nothing at all when a conversation has none — an empty row of chips is noise on a list where most conversations carry none. The dashboard takes the same `?label=env:prod` filter the API does, the way /audit takes its filters: repeatable, AND-combined, and a link you can send someone. Clicking a chip adds that label to whatever is already filtered, so two clicks narrow rather than replace. Two details the console forces: - **Read from `uri`, not `params`.** A LiveView gets no plug pipeline and its params are collapsed the same way Plug collapses them, so the repeated key has to come back off the URI. `LabelFilter.from_uri/1` runs the same parser the API route uses, so the console and the API cannot disagree about what `?label=env:prod` means. - **The path is built by hand**, not through `~p`'s query encoding, because the filter is a repeated `label=` key and the sigil builds a map. A filter value the vocabulary does not recognise filters nothing and leaves the chips showing what was asked for, the way the audit page treats a half-typed date. A filter that matches nothing says so rather than silently showing everything. Eighth of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
) Closes #1637. The concept page and the SDK guide get their sections, and the server CHANGELOG gets the entry the whole stack earns. The API reference already grew alongside each PR that built a piece of this. One release for the eight PRs beneath: `@managoat/fountain-sdk` 1.27.0, with `conversations({labels})`, `Conversation#setLabels()`, the generated types, and the query builder that expands an array into a repeated key. The earlier PRs regenerated types without bumping the version, because merging a bump publishes. Ninth of nine on #1637. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf
jhgaylor
force-pushed
the
stack/1637-console-chips
branch
from
September 11, 2026 03:10
eed5631 to
faf3ae6
Compare
jhgaylor
force-pushed
the
stack/1637-release
branch
from
September 11, 2026 03:10
893e5c3 to
f4f8c2c
Compare
jhgaylor
added a commit
that referenced
this pull request
Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the stack makes. `docs/api.md` gets the endpoint, the three-state body, what moves with it (the machine's binding identity, the skills reconciliation, the revision) and every refusal by status. `docs/concepts/conversation.md` gets the short version beside prompts and interrupts, and `docs/reference/webhooks.md` gets the `configuration`/`done` stage. Python, Elixir and Swift get the method, each in its own idiom for the one thing the wire format needs: an omitted field and an explicit null are different requests. Python uses an `_UNSET` sentinel, Elixir reads `Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate` rather than a plain optional that cannot tell "leave it" from "remove it". Each SDK's test asserts the encoded body, not just the call. Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to 0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed 1.27.0 on main while this stack was open. **Re-check the TypeScript version against `main` immediately before merge.** Concurrent bumps must land in ascending order, and this one has already been re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish. Checked: `docs-style.py` (two pre-existing findings, neither on a page this touches), `vale lint` (no new hit on any of the six gating rules), `destink` (112 pages clean), `docs_test.exs`, and all four SDK suites and contract checks, plus `swift test` (82 tests). Part 8 of 8 for #1565. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor
added a commit
that referenced
this pull request
Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the stack makes. `docs/api.md` gets the endpoint, the three-state body, what moves with it (the machine's binding identity, the skills reconciliation, the revision) and every refusal by status. `docs/concepts/conversation.md` gets the short version beside prompts and interrupts, and `docs/reference/webhooks.md` gets the `configuration`/`done` stage. Python, Elixir and Swift get the method, each in its own idiom for the one thing the wire format needs: an omitted field and an explicit null are different requests. Python uses an `_UNSET` sentinel, Elixir reads `Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate` rather than a plain optional that cannot tell "leave it" from "remove it". Each SDK's test asserts the encoded body, not just the call. Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to 0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed 1.27.0 on main while this stack was open. **Re-check the TypeScript version against `main` immediately before merge.** Concurrent bumps must land in ascending order, and this one has already been re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish. Checked: `docs-style.py` (two pre-existing findings, neither on a page this touches), `vale lint` (no new hit on any of the six gating rules), `destink` (112 pages clean), `docs_test.exs`, and all four SDK suites and contract checks, plus `swift test` (82 tests). Part 8 of 8 for #1565. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
This was referenced Sep 11, 2026
jhgaylor
added a commit
that referenced
this pull request
Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the stack makes. `docs/api.md` gets the endpoint, the three-state body, what moves with it (the machine's binding identity, the skills reconciliation, the revision) and every refusal by status. `docs/concepts/conversation.md` gets the short version beside prompts and interrupts, and `docs/reference/webhooks.md` gets the `configuration`/`done` stage. Python, Elixir and Swift get the method, each in its own idiom for the one thing the wire format needs: an omitted field and an explicit null are different requests. Python uses an `_UNSET` sentinel, Elixir reads `Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate` rather than a plain optional that cannot tell "leave it" from "remove it". Each SDK's test asserts the encoded body, not just the call. Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to 0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed 1.27.0 on main while this stack was open. **Re-check the TypeScript version against `main` immediately before merge.** Concurrent bumps must land in ascending order, and this one has already been re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish. Checked: `docs-style.py` (two pre-existing findings, neither on a page this touches), `vale lint` (no new hit on any of the six gating rules), `destink` (112 pages clean), `docs_test.exs`, and all four SDK suites and contract checks, plus `swift test` (82 tests). Part 8 of 8 for #1565. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor
added a commit
that referenced
this pull request
Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the stack makes. `docs/api.md` gets the endpoint, the three-state body, what moves with it (the machine's binding identity, the skills reconciliation, the revision) and every refusal by status. `docs/concepts/conversation.md` gets the short version beside prompts and interrupts, and `docs/reference/webhooks.md` gets the `configuration`/`done` stage. Python, Elixir and Swift get the method, each in its own idiom for the one thing the wire format needs: an omitted field and an explicit null are different requests. Python uses an `_UNSET` sentinel, Elixir reads `Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate` rather than a plain optional that cannot tell "leave it" from "remove it". Each SDK's test asserts the encoded body, not just the call. Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to 0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed 1.27.0 on main while this stack was open. **Re-check the TypeScript version against `main` immediately before merge.** Concurrent bumps must land in ascending order, and this one has already been re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish. Checked: `docs-style.py` (two pre-existing findings, neither on a page this touches), `vale lint` (no new hit on any of the six gating rules), `destink` (112 pages clean), `docs_test.exs`, and all four SDK suites and contract checks, plus `swift test` (82 tests). Part 8 of 8 for #1565. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor
added a commit
that referenced
this pull request
Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the stack makes. `docs/api.md` gets the endpoint, the three-state body, what moves with it (the machine's binding identity, the skills reconciliation, the revision) and every refusal by status. `docs/concepts/conversation.md` gets the short version beside prompts and interrupts, and `docs/reference/webhooks.md` gets the `configuration`/`done` stage. Python, Elixir and Swift get the method, each in its own idiom for the one thing the wire format needs: an omitted field and an explicit null are different requests. Python uses an `_UNSET` sentinel, Elixir reads `Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate` rather than a plain optional that cannot tell "leave it" from "remove it". Each SDK's test asserts the encoded body, not just the call. Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to 0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed 1.27.0 on main while this stack was open. **Re-check the TypeScript version against `main` immediately before merge.** Concurrent bumps must land in ascending order, and this one has already been re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish. Checked: `docs-style.py` (two pre-existing findings, neither on a page this touches), `vale lint` (no new hit on any of the six gating rules), `destink` (112 pages clean), `docs_test.exs`, and all four SDK suites and contract checks, plus `swift test` (82 tests). Part 8 of 8 for #1565. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor
added a commit
that referenced
this pull request
Sep 11, 2026
The manual and the three SDKs the API PR left, plus the one version bump the stack makes. `docs/api.md` gets the endpoint, the three-state body, what moves with it (the machine's binding identity, the skills reconciliation, the revision) and every refusal by status. `docs/concepts/conversation.md` gets the short version beside prompts and interrupts, and `docs/reference/webhooks.md` gets the `configuration`/`done` stage. Python, Elixir and Swift get the method, each in its own idiom for the one thing the wire format needs: an omitted field and an explicit null are different requests. Python uses an `_UNSET` sentinel, Elixir reads `Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate` rather than a plain optional that cannot tell "leave it" from "remove it". Each SDK's test asserts the encoded body, not just the call. Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to 0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed 1.27.0 on main while this stack was open. **Re-check the TypeScript version against `main` immediately before merge.** Concurrent bumps must land in ascending order, and this one has already been re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish. Checked: `docs-style.py` (two pre-existing findings, neither on a page this touches), `vale lint` (no new hit on any of the six gating rules), `destink` (112 pages clean), `docs_test.exs`, and all four SDK suites and contract checks, plus `swift test` (82 tests). Part 8 of 8 for #1565. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
jhgaylor
added a commit
that referenced
this pull request
Sep 12, 2026
The manual and the three SDKs the API PR left, plus the one version bump the stack makes. `docs/api.md` gets the endpoint, the three-state body, what moves with it (the machine's binding identity, the skills reconciliation, the revision) and every refusal by status. `docs/concepts/conversation.md` gets the short version beside prompts and interrupts, and `docs/reference/webhooks.md` gets the `configuration`/`done` stage. Python, Elixir and Swift get the method, each in its own idiom for the one thing the wire format needs: an omitted field and an explicit null are different requests. Python uses an `_UNSET` sentinel, Elixir reads `Keyword.has_key?`, and Swift has a three-case `ConversationBindingUpdate` rather than a plain optional that cannot tell "leave it" from "remove it". Each SDK's test asserts the encoded body, not just the call. Versions: TypeScript 1.27.0 to 1.28.0, Python 0.1.1 to 0.2.0, Elixir 0.1.0 to 0.2.0. Swift is unversioned. 1.28.0 rather than 1.27.0 because #1849 landed 1.27.0 on main while this stack was open. **Re-check the TypeScript version against `main` immediately before merge.** Concurrent bumps must land in ascending order, and this one has already been re-bumped once for exactly that reason. See the repo's note on `latest` moving with a publish. Checked: `docs-style.py` (two pre-existing findings, neither on a page this touches), `vale lint` (no new hit on any of the six gating rules), `destink` (112 pages clean), `docs_test.exs`, and all four SDK suites and contract checks, plus `swift test` (82 tests). Part 8 of 8 for #1565. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1637.
The concept page and the SDK guide get their sections, and the server
CHANGELOG gets the entry the whole stack earns. The API reference already
grew alongside each PR that built a piece of this.
One release for the eight PRs beneath:
@agentshit/fountain-sdk1.25.0, withconversations({labels}),Conversation#setLabels(), the generated types,and the query builder that expands an array into a repeated key. The earlier
PRs regenerated types without bumping the version, because merging a bump
publishes.
Ninth of nine on #1637.
The stack for #1637
Nine PRs, each based on the one above it. Merge top down, and do not
--delete-branchwhile a child still points at a branch.stack/1637-labels-columnConversations.Labels's limits,labelson createstack/1637-labels-writerset_conversation_labels/4,_unsafe_merge_labels/3, the sandbox rule, the audit event, the channel resumestack/1637-labels-apiPATCH /api/conversations/:id/labels,FountainWeb.SandboxKey, the 403,labelson the wirestack/1637-label-filter?label=key:value,RepeatedQueryParam,LabelFilter, the SDK filterstack/1637-team-labelsstack/1637-acp-stamp_fountain/labelsover the agent's own ACP sessionstack/1637-webhook-labelsdata.labelson everyconversation.*payloadstack/1637-console-chipsstack/1637-releaseThis is #1676 re-cut under the no-big-PRs rule, rebased onto current
main(re-rebased after #1832-#1839 landed).The combined tip is byte-identical to #1676 over
apps/, apart from threeprose fixes the destink and STE gates asked for and the regenerated contract
and SDK types.
Validation on the tip of the stack:
mix precommitclean, core and ee4,911 tests, 0 failures,
fountain_buzz144,fountain_support33. TheSDK contract
--check, the TypeScript typecheck and its 105 tests pass.docs-style.py,vale(0 errors) anddestinkare clean.🤖 Generated with Claude Code
https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf