Skip to content

Make and manage coworkers in dialogs - #317

Merged
guidovizoso merged 28 commits into
mainfrom
guido/agent-dialogs
Sep 1, 2026
Merged

Make and manage coworkers in dialogs#317
guidovizoso merged 28 commits into
mainfrom
guido/agent-dialogs

Conversation

@guidovizoso

Copy link
Copy Markdown
Collaborator

What this changes

Creating and managing a coworker both move out of the agents page's side panel and into dialogs, reachable from anywhere the coworker appears.

Creating is a three-step wizard (questionnaire component): who it is (name, title, role), who may see it, then where it runs — with Built in offered only when the deployment actually has a managed Bot to run it on. The server says so through a new GET /capabilities answer instead of letting the create bounce.

Managing is a dialog with a sidebar of sections — General (each field edited in place), Access (which connectors and skills it has been granted, grouped from its plugin refs), Connection, Handoff, Routines, and Manage (hide / duplicate / delete, with a stacked confirm). Below md the sidebar gives way to a scrollable strip of section buttons, so all of it is reachable on a phone.

The server now states two facts up front so screens stop offering controls it would only refuse:

  • builtIn on the agent DTO (endpoint compared against the configured managed endpoint, guarded on typeof string so an endpoint-less stub doesn't compare undefined === undefined into built-in). A built-in coworker's Connection tab now says the truth — it runs here, nothing to connect, nothing to authenticate — instead of nagging for a callback token it will never need.
  • grantable on the handoff answer (asked of the plugin store's own notion of where an agent runs). The handoff panel explains once when a coworker cannot hand work on, instead of a column of switches that bounce; existing grants stay visible so they can still be revoked.

Routines move onto each coworker. The sidebar's global Routines entry is gone (the /routines route still answers direct links); a coworker's routines are a section of its dialog, scoped by the agentId the routine DTO now carries. The row itself is rebuilt as an item whose footer wears its state as chips: the channel it posts to, the last run with a colored dot, and either the next run, Paused, or a pulsing Due when the stamp is already in the past (which used to render as "Next 5 hours ago").

The panel beside a conversation slims down to who-you-are-talking-to plus two buttons — start a new channel, or open the dialog. It used to duplicate the dialog's whole surface. agent-fields.tsx and the radio-group primitive go with it; the edit form was their last caller.

An open design question, deferred on purpose

A coworker created as "Built in" through the wizard is stored as remote_ag_ui pointed at the managed endpoint. Its tool callbacks work (the deployment-token path), but the handoff tool is minted only inside this deployment's own run loop, so such a coworker can be asked but cannot itself hand work on. The panel now says so honestly rather than pretending otherwise. Fixing it properly means either storing endpoint-less creates as built_in type, or adding a server-minted hop endpoint to the callback surface — worth its own PR either way.

Testing

  • tsc clean in both workspaces, biome clean across app/src and server/src.
  • Full suite: 2136 pass; the 8 fail + 1 error are the known local baseline (agent-bot/agent-langgraph missing their separate installs, plus 5 handoff integration failures that reproduce identically on a pristine origin/main worktree — this branch's server diff touches no handoff code).
  • Walked through in the browser: the wizard end to end (both run-modes), every dialog section, in-place edits, the stacked delete confirms, routines chips incl. Paused/Due, and the mobile strip at 390px.

Two columns on users say where somebody is in first-run onboarding and
when they finished; /api/me carries the status and one POST moves it.
The app redirects an unfinished user to /onboarding — an animated
three-step wizard — and an address the build does not know now goes
home through the same gate instead of a bare 404.
# Conflicts:
#	bun.lock
#	server/drizzle/meta/0015_snapshot.json
#	server/drizzle/meta/_journal.json
#	server/src/app.ts
#	server/src/index.ts
… next poll

The handoff queue was swept every two seconds, and a person is waiting through
every hop, so each leg of a handoff cost up to a full interval doing nothing.
Offering work now fires pg_notify with the kind as payload, inside the offering
transaction where there is one so it fires on commit and never before, and any
replica can listen on a dedicated connection and kick its sweep immediately.

A notification is a latency optimisation, never a delivery mechanism: one lost
in transit costs up to one poll interval, not the work, and the queue's tables
stay the only truth.
A transient busy flag on the channel activity event, announced and never
written: busy is a moment, not a fact about the channel, and a missed signal
costs at most a stuck-looking dot until the next real event, never data.

Two ways in. The server signals by thread (signalBusy) for the runs it can see
— the runtime's lock acquire and release now carry an onRunBusy seam, so every
run the platform processes lights its channel, including one whose tab has
navigated away — and a scratch thread maps to no channel and signals nowhere,
which is the point of a scratch thread. The browser signals by channel
(POST /:channelId/busy) for the one thing the server cannot see, a person's own
turn beginning, with a membership check so belonging to a channel is not
something an outsider can probe for.
A forward hop used to answer in the addressed Bot's own channel with the
person: two conversations for one question, and the answer landed somewhere
they never asked anything. Now the hop runs in a scratch thread of the
addressed Bot's own — minted per hop, never mapped to a channel, never shown —
and what it said comes home through a second queued hop that has the asking
Bot relay the answer, attributed, in the conversation the person is watching.

The delivery gathers the Bot's words from the stream as it goes past, because
the runner publishes the turn to the platform and the events are the one
chance to hear it. The relay rides the same durable queue as the turn that
produced it, so a pod dying between the two loses the relay to a retry rather
than for ever; the answerIn marker that stops a failure notice recursing stops
a relay relaying. Answers are clipped at 12k characters so a Bot that comes
back with a book cannot swamp the relaying run's prompt, and a backwards hop
reads only the tail of the asking conversation so relaying never grows slower
with the channel.

Wired in index.ts: the scratch thread replaces the direct-channel answerIn,
the roster announcement resolves thread to channel and happens only when the
turn said something, the asking channel is lit while a forward hop runs, and
the queue's new offered-work notification kicks the sweep so a person is not
waiting out a poll interval per leg.
Three bouncing dots badged on the channel's avatar, driven by the busy flag on
the activity socket. Socket-only and transient on the summary type: the roster
query never returns it, so it is undefined until a busy event arrives and drops
whenever the roster refetches — the acceptable failure for a hint about a
moment. The event patcher flips only the busy field: the spread that serves
ordinary activity would carry the event's null message onto the row and wipe
the preview, and busy is not activity, so the row does not re-sort either.

The channel reports its own turns over the new busy endpoint, fire-and-forget,
and deliberately does not clear on unmount: a turn keeps running server-side
after the person leaves the channel, and the server clears it when the run's
lock is released.
A relayed handoff answer runs on the server and lands in the thread with no
browser attached; the transcript restored history once, on mount, and would
show the new turn only after leaving and coming back. The chat now watches the
roster's own channel-list cache — the sidebar updating and the transcript
refreshing are one signal and cannot drift apart — and when this channel's
lastMessageAt advances to a moment a Bot authored, the durable history is read
again and messages whose ids the transcript has never seen are appended.
Appended by id, not compared by length, because the stored read keeps only what
the platform can parse and can be shorter than the screen while still holding
the news. Retried briefly, since the roster is patched when the turn is on
record with the runner and the platform's read can be a beat behind.

The chat also reports its own turns to the busy endpoint, keyed on whether a
turn is in flight, so the roster's working dots cover the one run the server
cannot see begin.
A small hotkey registry, one place on purpose: the binding a listener matches
against and the combo the settings page shows are the same record, so the list
under Preferences is what the keys actually do rather than what somebody
remembered they did — each key drawn as its own keycap, symbols on a Mac and
names elsewhere. Matching is exact rather than at-least, so Shift+N does not
fire on Cmd+Shift+N and shadow whatever the browser means by it, and a plain
Shift+letter combo is left alone while the focus is anywhere editable.

Bound in _authed rather than _app, so a person on settings or admin can start
a chat without first clicking back into the app frame. The new-channel
composer's recipient picker takes focus on arrival, so Shift+N then typing a
name is one motion.
The wizard shipped unformatted; biome's own output, no hand edits.
A coworker created with no endpoint runs on the deployment's managed Bot, and
a deployment without one refuses the create — after somebody has already
filled in the form. GET /api/agents/capabilities states it up front, static
per process because it is configuration rather than data, and registered above
the parameterised route so "capabilities" can never be read as an agent id.
The browser caches it forever for the same reason, through a new
agentCapabilitiesQueryOptions.
Three steps rather than one form: who it is (name, title, role), who can see
it, and where it runs — the fork where a built-in coworker needs nothing more
and a managed one reveals the endpoint and key fields. Built on the shadcn
questionnaire primitives for the choice cards and fieldset semantics, with
navigation driven by this dialog's own Continue/Back: the primitive's submit
path refuses items it does not consider answered and cannot see these fields.
Panes slide like onboarding — popLayout with a measured, height-following
frame whose 'relative' is what keeps the exiting pane clipped inside it.

Each step validates its slice of the shared form schema on Continue, and the
Built-in card is shown but disabled, with the reason, on a deployment whose
capabilities say it cannot back one. The visibility cards in AgentFields move
to the same radio-card idiom, on a radio-group primitive added for it.
The profile carries four distinct concerns — who it is, where it runs, what it
may hand work to, and what can be done to it — and the side panel stacked them
into one column that buried the later ones. Each is a section now, behind a
dialog-internal sidebar headed by the coworker's avatar and name.

General leads with name, title, role and visibility as items that edit in
place: Edit opens that field and that field alone, validated against the same
limits the server enforces, and visibility writes on pick because two named
choices leave no draft worth holding. Below them, the shortcuts — start
channel, duplicate, delete — each as an item with its button. Deleting
confirms in a dialog stacked over this one, with the name in the question and
a backdrop of its own; DialogContent grows overlayClassName for it, which also
forces the backdrop to exist, because Base UI skips backdrops on nested
dialogs and a stacked dialog is precisely the caller asking for one.
The roster keeps both states in the URL — ?new opens the multi-step create
dialog, ?agent=<id> the coworker's own — so Back still closes them and a link
still lands on them. The DetailPanel goes, and with it the old NewAgent pane
it slid in; the channel screen's use of the side-panel profile is untouched.
The card grids also stop stretching their tracks: auto-fill over the card's
own width, so the gutter is the gutter rather than whatever 1fr left over.
The history read is the one call in a delivery that throws on a platform
error, and it sat after the lock was acquired but before the try whose finally
gives the lock back: a 500 from the platform leaked the lock for its full TTL.
On a forward hop that lock is a scratch thread and the leak costs nothing; on
a relay it is the asking conversation itself, so the person could not type for
two minutes, the retry a minute later collided with the hop's own leftover
hold and spent an attempt on it, and a relay that ran out of attempts vanished
without a notice.

The read is of the conversation that asked, which the lock — taken on the
conversation being answered in — never protected. Hoisted above the acquire it
fails before anything is held, and the happy path holds the lock for less of
the turn too. Raised by kevin9327 on #290, who had the same reordering up as
#305 against main; a test now pins the ordering.
Finishing the wizard navigates straight into the app, and the channel list —
cached from before the wizard, or from a fetch that ran while it held the
screen — could greet the person with an empty sidebar their reload then fixed.
Seen once on a real deployment during review of #290 and not cleanly
reproduced, so this is a hardening of the seam rather than a confirmed fix:
both invalidations use refetchType "all" for the same reason, nothing is
observing these queries while the wizard is up.
Two review findings on the wizard's roster step. The invented agents that top
up a sparse deployment rendered identically to real ones, so "Support Agent"
read as a Bot the deployment has; they are dimmed and labelled Example now.
And max-w-lg had run together with overflow-hidden into a class that applied
neither, which is why the roster cards drew wider than every other step.

Also stops the onboarding store's comment promising that step is where the
wizard resumes: it is stored and served but nothing resumes from it yet, and
the comment now says so instead of describing a behaviour that does not
exist.
…stance

Five user-visible changes were on this branch with no entry: the relay, the
working indicator, the transcript catch-up, the onboarding wizard, and
Shift+N. Worse, 0.0.5's notes promised the asking Bot does not relay text on
the addressed Bot's behalf, and this branch makes it do exactly that — so
somebody upgrading off those notes got the opposite of what they were told.
The Unreleased section now names the reversal outright, and the 0.0.5
paragraph carries a pointer forward rather than standing uncorrected.
…it may hand work on

The agent DTO gains builtIn, computed by comparing the stored endpoint
against the managed endpoint the server was configured with, and the
handoff answer gains grantable, asked of the plugin store's own notion
of where an agent runs. Both exist so screens can stop offering
controls the server would only refuse: a built-in coworker was being
nagged for a callback token it will never need, and a remote coworker
was offered handoff switches that bounced.

The endpoint comparison guards on typeof string because two undefineds
compare equal, which quietly made every endpoint-less stub built-in.
The routine DTO now names its agent, so the list can be scoped to one
coworker's dialog while the Routines page keeps showing them all — one
owner-scoped query either way, the scope a filter rather than a second
endpoint.

The row itself is rebuilt as a muted item whose footer is a set of
chips: the channel it posts to (a link, since it is a place), the last
run with a colored dot carrying the tone, and either the next run,
Paused when switched off, or a pulsing Due when the stamp is already in
the past — which used to render as the nonsense 'Next 5 hours ago'.
Empty states go through the shared Empty component.
…ossible

Each candidate Bot is a muted item with its avatar and a switch, under
a header that answers 'how many of them' at a glance. A coworker that
cannot be granted the handoff tool — it runs as its own agent, outside
this deployment's loop — gets one explanation item instead of a column
of switches that can only bounce off the server's refusal; its stale
grants stay visible so they can still be revoked.
…, and a phone strip

The dialog gains an Access section (which connectors and skills this
coworker has been granted, grouped from its plugin refs), a Routines
section (the scoped list, replacing the global sidebar entry — the
/routines route still answers direct links), and a Connection tab that
tells a built-in coworker the truth: it runs on this deployment's own
Bot, nothing to connect and nothing to authenticate, instead of nagging
for a callback token.

Below the md breakpoint the sidebar gave way to nothing and most
sections were unreachable; a scrollable strip of section buttons now
takes its place, with the coworker's name above it and room left for
the close button.
The panel beside a conversation answers 'who am I talking to' — avatar,
name, role, two buttons: start a new channel with this coworker, or
open the management dialog. It used to duplicate the dialog's whole
surface (edit form, tokens, grants, delete), which was two places to
maintain and a sidebar that scrolled past the conversation it sat
beside.

agent-fields and the radio-group primitive go with it: the edit form
was their last caller, and the create wizard draws its own choices.
# Conflicts:
#	CHANGELOG.md
#	app/src/lib/hotkeys/use-hotkey.ts
#	app/src/routes/_authed/_app/agents/index.tsx
#	server/src/index.ts
`ItemDescription` clamps to two lines, which is right for a roster row whose description is a
subtitle and wrong for an item whose whole job is to explain. Both explanations here run to three,
and in each case the line that gets cut is the useful one: "It can still be asked by Bots that can"
is exactly what a person reading "this coworker cannot hand work on" needs next, and it was
invisible on screen while sitting in the DOM.

Seen on a deployment rather than in the file: the text reads complete in the accessibility tree and
truncated with an ellipsis in the browser.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drove this on a real single-user EKS deployment rather than reading it. The dialogs are a clear
improvement on the side panel, and the two new server facts do what the description says.

What I confirmed on a deployment

The wizard. Three steps, fields hold, Continue gates on step 1. On a deployment with no managed
Bot, Built-in is disabled and says why: "Not available here: this deployment has no Bot of its
own for a coworker to run on." I then set config.managedAgent.url and secrets.managedAgentToken
and re-checked: GET /api/agents/capabilities flips to builtInAvailable: true. That is the whole
point of the new endpoint and it works.

builtIn on the DTO, read live with the managed endpoint configured:

Knowledge          builtIn=false  endpoint=(none)
General Assistant  builtIn=false  endpoint=(none)
Review Bot         builtIn=false  endpoint=https://review-bot.example.com/ag-ui
Risk Analyst       builtIn=true   endpoint=http://…openbot-agent…:8000/ag-ui

The typeof endpoint === "string" guard is doing its job: the two endpoint-less coworkers stay
false rather than comparing two absents into true. Risk Analyst's Connection tab now reads "Runs on
this deployment's own Bot. Nothing to connect and nothing to authenticate" instead of asking for a
callback token, and Review Bot's still shows its endpoint and offers one. I went looking for a gap
where an endpoint-less coworker would be nagged and there isn't one: the branch is
!profile.endpoint || profile.builtIn, so both cases land on the same message.

grantable. Review Bot, created as managed, gets the explanation instead of a column of
switches: "Handing work on is a tool that runs inside this deployment's own loop, and this coworker
runs as its own agent." Keeping existing grants visible so they can still be revoked is the right
call.

Crappy path. Managed with no endpoint refuses client-side with "An endpoint is required for a
managed coworker" and reveals the field plus a Test button. Nothing reaches the server to bounce.

Also: system-owned coworkers correctly show no edit pencils, and the Routines entry is gone from the
sidebar as described.

One fix pushed

c4c8d4f. Both handoff explanations were clipped at two lines by ItemDescription's
line-clamp-2, and in each case the cut line was the useful one. "This coworker cannot hand work
on" ended at "It can still be…", with "asked by Bots that can" present in the DOM and invisible on
screen. That is the sentence a person needs next, so the clamp is overridden on those two items.
Worth knowing the clamp is there before writing another explanatory Item.

The test claim in the description

the 8 fail + 1 error are the known local baseline … reproduce identically on a pristine
origin/main worktree

They do not, and I would not carry that forward. On this branch with dependencies installed and no
dev server running: 2170 tests, 0 fail. The five handoff integration failures are the
bot.message queue collision with a running dev server, diagnosed on #290; stop the worker and they
pass. tests/agent-bot.test.ts passes here too. Both sets pass individually:

server/tests/agent-handoff-*.integration.test.ts   5 pass, 0 fail
tests/agent-bot.test.ts                            2 pass, 0 fail

It matters beyond tidiness: a suite believed to have eight expected failures is a suite where the
ninth goes unnoticed.

Smaller notes, take or leave

  • The endpoint field appears only after pressing Create coworker, so the form grows under the
    person at the moment they expected to be finished. Revealing it when Managed is selected would
    cost nothing.
  • builtIn reads as "is a built-in coworker" but means "points at the configured managed endpoint",
    which is why the package's own built_in Bots report false. The behaviour is right and the doc
    comment is clear; the name is the only part that misleads.

@guidovizoso
guidovizoso merged commit 33b3f60 into main Sep 1, 2026
13 checks passed
@guidovizoso
guidovizoso deleted the guido/agent-dialogs branch September 1, 2026 17:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants