Skip to content

feat(team): labels on a teammate message, and on the teammate's list (#1637) - #1845

Merged
jhgaylor merged 2 commits into
mainfrom
stack/1637-team-labels
Sep 11, 2026
Merged

feat(team): labels on a teammate message, and on the teammate's list (#1637)#1845
jhgaylor merged 2 commits into
mainfrom
stack/1637-team-labels

Conversation

@jhgaylor

@jhgaylor jhgaylor commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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.


The stack for #1637

Nine PRs, each based on the one above it. Merge top down, and do not
--delete-branch while a child still points at a branch.

# branch owns
1 stack/1637-labels-column the jsonb column, the GIN index, Conversations.Labels's limits, labels on create
2 stack/1637-labels-writer set_conversation_labels/4, _unsafe_merge_labels/3, the sandbox rule, the audit event, the channel resume
3 stack/1637-labels-api PATCH /api/conversations/:id/labels, FountainWeb.SandboxKey, the 403, labels on the wire
4 stack/1637-label-filter ?label=key:value, RepeatedQueryParam, LabelFilter, the SDK filter
5 stack/1637-team-labels labels on a team message, and the same filter on a teammate's list
6 stack/1637-acp-stamp _fountain/labels over the agent's own ACP session
7 stack/1637-webhook-labels data.labels on every conversation.* payload
8 stack/1637-console-chips chips on the console lists, and the dashboard URL filter
9 stack/1637-release the manual, the CHANGELOG, one SDK release (1.27.0)

This 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 three
prose fixes the destink and STE gates asked for and the regenerated contract
and SDK types.

Validation on the tip of the stack: mix precommit clean, core and ee
4,911 tests, 0 failures, fountain_buzz 144, fountain_support 33. The
SDK contract --check, the TypeScript typecheck and its 105 tests pass.
docs-style.py, vale (0 errors) and destink are clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_018SgxmwtJNGJBvgCxSfGBaf

@jhgaylor jhgaylor added the release:skip-sdk Explicitly allows an SDK surface change without releasing a new package version. label Sep 10, 2026
@jhgaylor
jhgaylor force-pushed the stack/1637-label-filter branch from e854bc5 to 9fcabc5 Compare September 10, 2026 13:45
@jhgaylor
jhgaylor force-pushed the stack/1637-team-labels branch from 8278957 to 7781635 Compare September 10, 2026 13:45
@jhgaylor
jhgaylor force-pushed the stack/1637-label-filter branch from 9fcabc5 to 6ceca0b Compare September 11, 2026 02:27
@jhgaylor
jhgaylor force-pushed the stack/1637-team-labels branch from 7781635 to fd06276 Compare September 11, 2026 02:27
@jhgaylor
jhgaylor force-pushed the stack/1637-label-filter branch from 6ceca0b to ca05537 Compare September 11, 2026 02:44
@jhgaylor
jhgaylor force-pushed the stack/1637-team-labels branch from fd06276 to 84f534d Compare September 11, 2026 02:44
@jhgaylor
jhgaylor force-pushed the stack/1637-label-filter branch from ca05537 to bfaa827 Compare September 11, 2026 02:53
@jhgaylor
jhgaylor force-pushed the stack/1637-team-labels branch from 84f534d to 260d1ba Compare September 11, 2026 02:53
jhgaylor and others added 2 commits September 10, 2026 23:10
…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
@jhgaylor
jhgaylor force-pushed the stack/1637-label-filter branch from bfaa827 to e0bedf5 Compare September 11, 2026 03:10
@jhgaylor
jhgaylor force-pushed the stack/1637-team-labels branch from 260d1ba to 57043c2 Compare September 11, 2026 03:10
@jhgaylor
jhgaylor changed the base branch from stack/1637-label-filter to main September 11, 2026 03:10
@jhgaylor
jhgaylor merged commit d23b118 into main Sep 11, 2026
24 checks passed
@jhgaylor
jhgaylor deleted the stack/1637-team-labels branch September 11, 2026 03:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:skip-sdk Explicitly allows an SDK surface change without releasing a new package version.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants