Skip to content

Commit 33b3f60

Browse files
Make and manage coworkers in dialogs (#317)
* Gate first sign-in behind a per-user onboarding wizard 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. * Say work is offered aloud, so a sweep can start now instead of at its 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. * Let a channel tell its members a turn is running in it 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. * Relay a handoff answer back into the conversation that asked 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. * Show a channel working on the roster while a turn runs in it 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. * Keep an open channel current with turns nobody here streamed 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. * Start a new chat from anywhere with Shift+N 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. * Format the onboarding route The wizard shipped unformatted; biome's own output, no hand edits. * Say whether this deployment can make a built-in coworker 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. * Create a coworker in a multi-step questionnaire dialog 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. * Show a coworker in a sectioned dialog with a sidebar of its own 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. * Open coworkers in dialogs from the agents screen 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. * Sort two files' imports the way biome's assist asks * Read the asking conversation before taking the run's lock 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. * Refetch the roster as well as the user when onboarding completes 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. * Say which wizard agents are examples, and unstick its two classes 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. * Say in the changelog what changed, including reversing 0.0.5's relay 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. * Say whether a coworker runs on the deployment's own Bot, and whether 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. * Show routines per coworker, as rows that wear their state 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. * Rebuild the handoff panel on items, and say once when granting is impossible 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. * Grow the coworker dialog: access, routines, a truthful connection tab, 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. * Slim the channel's coworker panel to a card that opens the dialog 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. * Say in the changelog what the dialogs change for the person running OpenBot * Let the handoff explanations finish their sentence `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. --------- Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
1 parent b85f361 commit 33b3f60

20 files changed

Lines changed: 2297 additions & 704 deletions

File tree

CHANGELOG.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,32 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### Coworkers are made in a wizard and managed in a dialog
12+
13+
Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs,
14+
with **Built in** offered only when the deployment actually has a managed Bot to run it on.
15+
Managing one is a dialog opened from wherever the coworker appears, with sections for its profile
16+
(each field edited in place), what it may reach, its connection, handoff grants, routines, and the
17+
hide/duplicate/delete verbs — usable on a phone, where the old side panel hid most of this. The
18+
panel beside a conversation slims down to who-you-are-talking-to plus two buttons: start a new
19+
channel, or open that dialog.
20+
21+
### Routines live on each coworker
22+
23+
The sidebar's global Routines entry is gone; a coworker's routines are a section of its own dialog,
24+
since a routine is something *it* carries out. The `/routines` page still answers direct links.
25+
Each routine row now wears its state as chips — the channel it posts to, how the last run went, and
26+
either the next run, **Paused**, or **Due** when a firing is waiting on the sweep (which used to
27+
render as "Next 5 hours ago").
28+
29+
### A built-in coworker is no longer asked for credentials it will never need
30+
31+
A coworker running on the deployment's own Bot was nagged for a callback token and shown an
32+
endpoint form. Its connection tab now says what is true — it runs here, nothing to connect, nothing
33+
to authenticate. In the same spirit, the handoff panel explains once when a coworker cannot hand
34+
work on (it runs as its own agent, outside this deployment's loop) instead of offering switches the
35+
server can only refuse; its existing grants stay visible so they can still be revoked.
36+
1137
### A hop the boundary refused now names the Bot that was refused
1238

1339
The audit page renders its Bot column from `payload.bot` and nothing else. `agent.handoff_offered`
@@ -16,6 +42,7 @@ refused, a hop retried, and a hop that failed for good — were not, so they sho
1642
Bot belongs. Those are the rows somebody actually opens the trail for: a hop that happened is visible
1743
in the transcript anyway, and a refused or lost one is visible nowhere else. All four now name the
1844
asking Bot, exactly as the accepted pair and `agent.escalated` already did.
45+
1946
### A failed tool refresh no longer leaves a connector offering nothing
2047

2148
Refreshing a connector's tools replaced the list with a delete and then an insert, as two separate
@@ -26,6 +53,7 @@ once, every grant an administrator had made was silently un-offered, and the Bot
2653
none of that vendor's tools. Nothing brought them back until somebody read the error on the Plugins
2754
page and pressed Refresh. The two statements are now one, so a bad refresh is recorded and the tools
2855
already held are left alone, which is what the code always claimed to do.
56+
2957
### A rule tried in dry-run now says what it would have refused a Bot's tools
3058

3159
`dry-run` exists so a boundary can be measured against live traffic before it starts refusing
@@ -36,6 +64,7 @@ about `mcp.server`, `mcp.tool` or `mcp.effect` therefore looked inert, and enfor
3664
refusing Bots with nothing in the trail to have warned anybody. A refused tool call is now recorded
3765
whatever the mode does with it, carrying `carriedOut` so a reader can tell a call this deployment
3866
stopped from one dry-run recorded and let past. Enforcing deployments behave exactly as before.
67+
3968
### A policy dry-run no longer counts a failed action twice, or invents a change it did not make
4069

4170
Testing a boundary against recent history replayed three kinds of audit row, and one of them is a
@@ -45,6 +74,7 @@ carries a refused action out, so a refused action can fail too — and its two r
4574
decision row saying "refused" and the failure row reading as "allowed", so a candidate policy that
4675
refused it identically was reported as a new refusal it never introduced. The replay now scores each
4776
action once, from the row that recorded its decision.
77+
4878
### A message no longer routes to a specialist because a longer word contained a connector's name
4979

5080
When the intent router falls back — it is unreachable, or it declines — and exactly one coworker can
@@ -53,6 +83,7 @@ substring, so "how do I deal with a slacker" matched the **slack** connector and
5383
giraffe) matched **jira**: a message naming neither system was pinned, for the life of the thread,
5484
to a specialist that could not answer it. A connector's name now has to appear on a word boundary,
5585
so a system named on its own still routes and one buried inside another word does not.
86+
5687
### The audit page no longer says "Allowed" about six kinds of refusal
5788

5889
A hop one Bot was not allowed to make, an endpoint this deployment would not dial, a rotation the
@@ -62,6 +93,7 @@ attempts and a question that reached nobody, which are "Did not happen" rather t
6293
page recognised six refusal types and the six added since were never added to it. Refusals now read
6394
as refusals, the two saved views are built from the same lists the rows are labelled from, and a
6495
refusal added later is added in one place or in none.
96+
6597
### A conversation deleted while a server was reconnecting no longer lingers on the screen
6698

6799
Announcements between servers travel as Postgres notifications, which reach whoever is subscribed at
@@ -75,6 +107,7 @@ showing a conversation that had been deleted until the page was reloaded.
75107

76108
A server now tells the browsers it is holding to refetch when its subscription is re-established.
77109
Nothing to configure, and no change for a deployment whose database connection never drops.
110+
78111
### A Bot's answer comes back to the conversation that asked
79112

80113
**This reverses what 0.0.5 shipped.** The 0.0.5 notes below say the asking Bot does not relay text
@@ -162,6 +195,7 @@ cluster and is not empty is now refused with a sentence naming the mount to use
162195

163196
Reported by [@jerelvelarde](https://github.com/CopilotKit/OpenBot/issues/269) with the container logs
164197
for both mount paths, which is what made the two failure modes separable.
198+
165199
### A sign-in a site opens in a new window is shown, and can be clicked
166200

167201
A Bot's browser was bound to the page it launched with, and to nothing the site opened afterwards.
@@ -177,6 +211,7 @@ afterwards with the same "take a new snapshot" it already gives after a navigati
177211
cannot act on the wrong document.
178212

179213
Nothing to configure.
214+
180215
### Stopping a Bot's computer stops it, and the person watching is told
181216

182217
A computer somebody stopped came back up on its own about a second later, and reset did the same. The
@@ -336,6 +371,7 @@ uses. It still cannot address another pod, a node, or a cloud metadata endpoint.
336371
destination and so permitted everything. That rule now covers the API server alone, and
337372
`networkPolicy.kubernetesApiCidr` narrows it to your cluster's service range; left empty it stays as
338373
it was, because a chart cannot know that range.
374+
339375
### Taking the wheel stops the Bot's shell, not just its clicks
340376

341377
While a person held the wheel the Bot was refused on the page, and not in the shell. `/exec` and a
@@ -350,6 +386,7 @@ to be able to say what it was doing.
350386

351387
Nothing to configure. A Bot that acts during a takeover gets the refusal it already got for a click,
352388
and the trail records the attempt and the failure the same way.
389+
353390
### A computer that was suspended once suspends again
354391

355392
Scale-to-zero worked once per Bot. A computer suspended, resumed, used and then left alone again was
@@ -366,6 +403,7 @@ the same clock the offer runs on, so a Bot cannot come back round as idle until
366403

367404
Nothing to configure, and the sweep already runs on a schedule. A deployment where each Bot has its
368405
own computer stops paying for browsers that were used once.
406+
369407
### A Bot's egress proxy is reachable on Kubernetes, or the install is refused
370408

371409
The chart named no egress variable anywhere, so a Helm deployment read the per-Bot proxy settings
@@ -508,6 +546,7 @@ unknown session means "no opinion" and skips the generation check, so on exactly
508546
shape it was written for, the check that stops a ref from a replaced computer resolving against a
509547
live one was silently absent. It now asks the supervisor when it does not know, by listing rather
510548
than by ensuring, so asking never starts a computer that had stopped.
549+
511550
### A routing trail says why a message was not routed, not only that it was not
512551

513552
Every untagged message writes a `channel.routed` row, and that row carried `fallback: true` for two
@@ -599,6 +638,7 @@ credential is refused rather than quietly attached to fail on its next call.
599638
Curated servers keep working as they did. Their URL comes from the catalogue rather than the
600639
request, and a per-instance hostname is matched against the vendor's own anchored pattern before
601640
anything is stored, so re-adding one cannot point it at an address of the caller's choosing.
641+
602642
### A configured egress proxy reaches the browser that uses it
603643

604644
`EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_<BOT>` were documented as the way to give a Bot a stable
@@ -620,6 +660,7 @@ given them. It is optional, so a deployment with no proxy is unchanged, and giti
620660
proxy URL can carry a password.
621661

622662
**Move these two out of `.env` and into `egress.env`.** In `.env` they reach no process.
663+
623664
### Screens without a conversation stop polling for a Bot that does not exist
624665

625666
Every surface asks which components its Bot holds, and asks again every few seconds so a revoked
@@ -634,6 +675,7 @@ that matters is invisible.
634675

635676
The grant queries now wait for a surface to declare a real Bot, and simply do not run while the
636677
placeholder holds. Conversation surfaces — the Bot page, channels — declare one and are unchanged.
678+
637679
### A rule can be tested against history before it is saved
638680

639681
A boundary was written blind: an administrator typed a CEL rule, saved it, and learned what it
@@ -713,6 +755,7 @@ this port has to reach it another way**, which is what publishing it on every in
713755
This does not reach back in time. A deployment that has been running with the two on one network
714756
should assume a Bot could have read or written the database, and look at the trail with that in
715757
mind.
758+
716759
### A credential in an MCP server address is refused in the query and the fragment too
717760

718761
Refusing `https://user:token@vendor.example/mcp` closed the userinfo spelling of a credential in the
@@ -739,6 +782,7 @@ beside `metadata.google.internal`, and it carries a dot and none of the suffixes
739782
it read as an ordinary vendor name. The long spelling was only ever refused incidentally, by the
740783
`.internal` rule. Both are now named, so the address this check was written for is refused on purpose
741784
rather than by luck.
785+
742786
### A curated MCP server is pointed at its own kind of credential too
743787

744788
Adding a server by URL was made to check which credential it is being pointed at. Adding one from the
@@ -1012,6 +1056,7 @@ one they are, so those match too.
10121056

10131057
No configuration changes and nothing is stored differently; a deployment that was already on the
10141058
light theme sees no difference at all.
1059+
10151060
### `start.sh` refuses a port that answers but is not OpenBot
10161061

10171062
The startup checks asked whether a port answered, and treated that as proof the port belonged to this
@@ -1076,6 +1121,7 @@ the decision row is written, so an action somebody tried to take still appears o
10761121
**A deployment may see refusals it did not see before.** That is the point: those are the actions that
10771122
were being carried out without the boundary seeing what they touched. A Bot that meets one takes a
10781123
fresh snapshot and continues.
1124+
10791125
### A package ships its skills, so tool selection works on a clone
10801126

10811127
Tool selection narrows a Bot's tools to the ones its matching skills declare, and a deployment starts

0 commit comments

Comments
 (0)