Setting up needs one Intelligence credential, not two
COPILOTKIT_LICENSE_TOKEN is no longer required. Managed Intelligence derives entitlement from the
project key, so the second credential people were sent to fetch had stopped existing, and startup
was still refusing to boot without it. A deployment now needs INTELLIGENCE_API_URL,
INTELLIGENCE_GATEWAY_WS_URL and INTELLIGENCE_API_KEY. A licence token is still read and still
forwarded to the runtime when set, which is what a self-hosted Intelligence with its own licence
needs.
The Helm chart failed harder than the docs did: it required secrets.licenseToken, so a
managed-Intelligence install was refused at helm install rather than merely misdocumented. That
value is now optional.
Duplicating a coworker keeps the endpoint it was copied from
Duplicate used to point every copy at this deployment's own managed Bot, whatever the coworker being
copied ran on. Duplicating one you host yourself gave back something that looked identical on every
screen, carried the same name, title and role, and answered from a different process. The copy's
connection tab then said it ran here and stopped showing an endpoint at all, so the swap was
invisible in the one place you would have checked. A copy now runs where its original ran, and the
managed Bot is used only when the coworker being copied had no endpoint of its own, which is the
same fallback that applies when you create one without an endpoint. It does not inherit the
original's key: that is a reference into the vault, and two coworkers sharing one credential would
mean rotating either one's key silently changed the other's, so a copy starts without one. On a
deployment with no managed Bot configured, duplicating a coworker that brought its own endpoint now
works instead of being refused with advice to give it an endpoint it already had.
Connecting an account survives a vendor that is down
Finishing a connection used to end on a blank server error if the vendor could not be reached at the
moment you were sent back, whether that was a refused connection, a name that would not resolve, or
fifteen seconds of silence. It now ends where every other failed connect already ended: back on
Connected accounts with a note, and nothing stored. Pressing Connect for a vendor this deployment
has not introduced itself to yet behaves the same way, answering 502 rather than a server error, and
that message now covers a vendor that could not be reached as well as one that turned the
registration down.
A connection whose grant cannot be written to the vault ends the same way, rather than on the blank
error it used to give somebody who had just finished consenting.
Because the person is told the same thing whatever went wrong, the server log is now where the
difference lives. Three lines to look for: oauth-token-endpoint-unreachable and
oauth-registration-endpoint-unreachable name the vendor and the cause, and
oauth-connection-not-recorded says a consent completed and could not be kept. A fourth,
oauth-token-endpoint-unusable, means the fault is this deployment's catalogue rather than the
vendor.
A custom MCP server token is sent with the scheme it names
Every token stored against a custom MCP server went out as Bearer, whatever the vendor asked for.
A server that forwards the header to an API speaking Basic auth still answers the handshake and the
tool listing, so the Plugins page showed the connector connected and its tools offered, and every
real call came back 401. DataForSEO's hosted server behaves exactly this way.
A token that begins with Basic or Bearer is now sent as written, so paste the credential the
vendor gives you, scheme and all. A bare token is still sent as Bearer, so nothing already
working needs to change.
A conversation is no longer stuck after a tool call went unanswered
A tool that runs in the browser can be torn down while its call is still open, most often because
the tab was closed or reloaded mid-run. The call stayed in the thread with no result, every retry
sent it back up, and the model API refused the whole conversation with Tool result is missing for tool call .... The next three things the person typed failed identically, and the only way out was
to notice that and start another channel.
A chat turn now drops a tool call nothing is going to answer before the conversation reaches the
model, on a built-in Bot and on a remote one alike. Routines already did this for the history they
seed, and now share the one filter, with a stricter rule than theirs was: a result counts as an
answer only if it arrives before the next thing the person said, matching what the model API
enforces, so a handler that resolves after the person has typed again no longer looks like an
answer. A routine's seeded history that held such a late result used to keep both halves and fail
at the model; it now drops both and runs. A result that sits ahead of its own call, or a second
answer to a call already answered, is dropped as well rather than sent where no provider accepts
it. Ids are never rewritten and the stored thread is untouched, so the transcript still shows what
happened and a call waiting on a resume still gets its result. The same filter also covers a Bot
answering a relayed question, whose seeded conversation kept every tool call and no tool result.
Reopening a channel no longer hides the end of the last conversation
Opening a channel joins the realtime gateway, and the snapshot the join returns can lag the durable
store. When it did, the last exchange of a finished turn was missing from the transcript on every
reload, with no unread marker or anything else to explain the gap, and the stored copy never got a
chance to replace it because history was only restored into an empty channel.
The stored thread now wins whenever it holds more than the channel does and holds everything the
channel already shows. A message typed while history is still loading is not in the store yet, and
a run still streaming has messages the store has not seen, so neither is rolled back.
The transcript stays with the question when an answer starts arriving
Sending a message carried it up to the top of the view, correctly, and then the Bot's first token
threw the conversation back to its very first message, with the answer being written several
screens below the fold. It happened on every turn, from wherever the reader happened to be
scrolled, so every answer began with a scroll back down to find it. The transcript now holds the
question in place for the whole turn, and the scroll that puts it there is animated rather than a
jump — unless the reader has asked their system for reduced motion.
A conversation started from the sidebar is recorded like one started from the home screen
The trail had a channel.routed row for every conversation begun in the home composer — the
coworker it went to and why, whether inferred or named with @ — and nothing at all for one begun
from the sidebar's +, a coworker's card or its profile, which read exactly like a row that failed
to write. Picking a coworker in that To: field is now recorded the same way an @ is.
A browser clock that runs ahead no longer hides what a routine said
The roster line and unread dot for a channel are moved by the last report that arrived, and only
ever forwards. A browser whose clock was ahead stamped its report into the future, and then every
report from a correct clock — a routine's reply, a relayed handoff answer, another member's browser
— was dropped without a word until the real time caught up: the reply was in the thread, and the
roster never said so. A reported time is now capped at the server's own clock.
An empty supervisor PORT is unset, not an ephemeral bind
PORT= left blank in compose or a .env used to reach Bun.serve as NaN, so the supervisor
bound a random port while the published mapping still pointed at 4300. Empty now means the default
4300; a non-numeric or out-of-range value refuses to start instead of binding port 30 from a typo
like 30o0.
Tool arguments and results are redacted in the audit trail whatever their spelling
The sensitive-key list redacted tool_result and tool_arguments but not toolResult and
toolArguments, which is the spelling MCP and computer tool calls use. Those payloads were stored
verbatim in audit_events.payload, nested ones included, while every other sensitive key already
carried both spellings. New rows are redacted; rows already written are not rewritten, so a
deployment that has been running MCP or computer tools still holds unredacted arguments and results
in its existing trail.
An IPv6 address listed in AGENT_ENDPOINT_ALLOWED_HOSTS is now actually allowed
A bracketed IPv6 host was normalised one way when the list was read and another way when an endpoint
was checked, so the two could never match: [::1]:8080 became ::1]:8080 on one side and
::1:8080 on the other. Registering a Bot at a listed IPv6 address was refused as a private
address anyway. IPv4 was unaffected.
An empty PORT no longer starts the server on a port nobody asked for
PORT and SERVER_PORT name one number, and either is meant to move the server. A PORT that was
declared but empty — a compose file passing a variable the host never set, or PORT= left in a
.env next to a SERVER_PORT that was set — was read as "set to nothing": SERVER_PORT was
ignored, the number parsed to NaN, and the server came up on an ephemeral port while everything
that polls SERVER_PORT reported it had never started. An empty value now counts as unset, the way
every other setting already treats it, and a value that is not a whole port number (30o1 used to
start the server on port 30) refuses to start instead.
TRUSTED_ORIGINS falls back to the port the app is actually served on
Unset, it fell back to http://localhost:3000, while the app is served on 3010 everywhere else in
this repository. appUrl reads the first trusted origin, so a deployment that left the variable
blank built OAuth redirects against a port nothing was listening on.
Coworkers are made in a wizard and managed in a dialog
Creating a coworker is now a three-step wizard — who it is, 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.
Managing one is a dialog opened from wherever the coworker appears, with sections for its profile
(each field edited in place), what it may reach, its connection, handoff grants, routines, and the
hide/duplicate/delete verbs — usable on a phone, where the old side panel hid most of this. The
panel beside a conversation slims down to who-you-are-talking-to plus two buttons: start a new
channel, or open that dialog.
Routines live on each coworker
The sidebar's global Routines entry is gone; a coworker's routines are a section of its own dialog,
since a routine is something it carries out. The /routines page still answers direct links.
Each routine row now wears its state as chips — the channel it posts to, how the last run went, and
either the next run, Paused, or Due when a firing is waiting on the sweep (which used to
render as "Next 5 hours ago").
A built-in coworker is no longer asked for credentials it will never need
A coworker running on the deployment's own Bot was nagged for a callback token and shown an
endpoint form. Its connection tab now says what is true — it runs here, nothing to connect, nothing
to authenticate. In the same spirit, the handoff panel explains once when a coworker cannot hand
work on (it runs as its own agent, outside this deployment's loop) instead of offering switches the
server can only refuse; its existing grants stay visible so they can still be revoked.
A channel stops showing a working indicator once its turn has ended
Sending a message and then opening another channel before the reply arrived left the first channel
showing three bouncing dots on the roster, and they stayed there after the answer had landed and
been drawn into its preview, until the roster was refetched for some unrelated reason. A person's
own turn is reported by the browser, because the server is not told when one begins, and that
reporting was keyed on state belonging to the channel screen: opening another channel replaced the
screen, and the replacement reported the channel it had just opened rather than the one still
working. The report now belongs to the turn instead of to the screen, so the channel that was
running is the channel told when it stops. Opening a channel also no longer announces that it is
idle twice before anything has run in it.
A hop the boundary refused now names the Bot that was refused
The audit page renders its Bot column from payload.bot and nothing else. agent.handoff_offered
and agent.handoff_delivered were given that key; the four rows either side of them — a hop
refused, a hop retried, and a hop that failed for good — were not, so they showed a dash where the
Bot belongs. Those are the rows somebody actually opens the trail for: a hop that happened is visible
in the transcript anyway, and a refused or lost one is visible nowhere else. All four now name the
asking Bot, exactly as the accepted pair and agent.escalated already did.
A failed tool refresh no longer leaves a connector offering nothing
Refreshing a connector's tools replaced the list with a delete and then an insert, as two separate
statements. Whenever the second did not land — a pod killed mid-refresh, a dropped connection, or a
server answering tools/list with the same tool name twice, which the table refuses — the delete had
already committed on its own. The table is shared, so every replica lost that connector's tools at
once, every grant an administrator had made was silently un-offered, and the Bot was told it holds
none of that vendor's tools. Nothing brought them back until somebody read the error on the Plugins
page and pressed Refresh. The two statements are now one, so a bad refresh is recorded and the tools
already held are left alone, which is what the code always claimed to do.
A rule tried in dry-run now says what it would have refused a Bot's tools
dry-run exists so a boundary can be measured against live traffic before it starts refusing
anybody. It worked that way for the browser, and not for connectors: a tool call the rule matched was
recorded only as the call that then went out, so Blocked on the audit page — and any query behind
it — answered "this rule would have refused none of them" about calls it would have refused. A rule
about mcp.server, mcp.tool or mcp.effect therefore looked inert, and enforcing it started
refusing Bots with nothing in the trail to have warned anybody. A refused tool call is now recorded
whatever the mode does with it, carrying carriedOut so a reader can tell a call this deployment
stopped from one dry-run recorded and let past. Enforcing deployments behave exactly as before.
A policy dry-run no longer counts a failed action twice, or invents a change it did not make
Testing a boundary against recent history replayed three kinds of audit row, and one of them is a
duplicate: a permitted action that fails is recorded both as the decision that allowed it and as a
separate failure row, so every failed action was scanned and scored twice. Worse, a dry-run policy
carries a refused action out, so a refused action can fail too — and its two rows disagree, the
decision row saying "refused" and the failure row reading as "allowed", so a candidate policy that
refused it identically was reported as a new refusal it never introduced. The replay now scores each
action once, from the row that recorded its decision.
A message no longer routes to a specialist because a longer word contained a connector's name
When the intent router falls back — it is unreachable, or it declines — and exactly one coworker can
reach a system the message names, the message goes to that coworker. The name was matched as a bare
substring, so "how do I deal with a slacker" matched the slack connector and "una jirafa" (a
giraffe) matched jira: a message naming neither system was pinned, for the life of the thread,
to a specialist that could not answer it. A connector's name now has to appear on a word boundary,
so a system named on its own still routes and one buried inside another word does not.
The audit page no longer says "Allowed" about six kinds of refusal
A hop one Bot was not allowed to make, an endpoint this deployment would not dial, a rotation the
vault refused and a sign-in it turned away were all drawn as Allowed, in the muted colour every
ordinary row uses, and none of them appeared under Blocked. The same for a hop that ran out of
attempts and a question that reached nobody, which are "Did not happen" rather than allowed. The
page recognised six refusal types and the six added since were never added to it. Refusals now read
as refusals, the two saved views are built from the same lists the rows are labelled from, and a
refusal added later is added in one place or in none.
A conversation deleted while a server was reconnecting no longer lingers on the screen
Announcements between servers travel as Postgres notifications, which reach whoever is subscribed at
the moment they are sent and are never replayed. While a server's subscription was down — a database
restart, a failover, a rolling upgrade — every channel deletion, pin and message announced in that
window was lost, and nothing afterwards asked for it again.
The browser could not notice. Its own connection to the server stayed open throughout, so the
refetch it already does when that connection comes back was never triggered, and the roster went on
showing a conversation that had been deleted until the page was reloaded.
A server now tells the browsers it is holding to refetch when its subscription is re-established.
Nothing to configure, and no change for a deployment whose database connection never drops.
A Bot can answer with a picture instead of describing one
Ask for a chart and a Bot replied in prose, or handed back a fenced block of HTML for somebody to
read instead of look at. Set OPENBOT_GENERATIVE_UI=true and it may answer with an interface it
writes itself, drawn in the transcript. Off unless asked for, and deliberately so: it runs code a
model wrote, so a deployment acquires the capability by choosing it rather than by upgrading.
The sidebar collapses, and the roster is reachable on a phone
The sidebar could always collapse, but nothing in the app ever drew the trigger. The only
affordance was a 16px transparent rail carrying tabIndex={-1}, which the eye could not find and
the keyboard could not reach. Below 768px that same sidebar becomes a sheet whose open state starts
false, so with no trigger the roster was unreachable on a phone, and the roster is how you reach a
channel, Skills, Agents and your own account. There is now a toggle in the header each screen
already draws, with Cmd/Ctrl+B on it, and the collapsed choice survives a reload: the state was
written to a cookie that nothing ever read back. Fifteen screens that previously drew no header bar
gain 40px above their heading, because the toggle has to sit at the pane's edge.
A button drawn as a link answers the keyboard and announces itself
Six controls navigate rather than submit, so they render a router link through the shared button:
New skill, New agent, the sidebar's new-channel control, two empty-state returns, and the back
button that draws on five routes. Base UI was told each was a native <button>, so it wrote
type="button" onto an anchor, where it means nothing, and skipped the two things a non-button
needs: the role="button" that tells a screen reader what the control is, and Space-key
activation, which a <button> gets from the browser and an anchor does not.
A Bot's answer comes back to the conversation that asked
This reverses what 0.0.5 shipped. The 0.0.5 notes below say the asking Bot does not relay text
on the addressed Bot's behalf, and the answer lands in that Bot's own conversation. In practice
that meant reading the answer somewhere you never asked anything, so it is now the other way
around: the addressed Bot works in a scratch conversation nobody is shown, and the asking Bot
relays what came back — attributed by name — into the conversation you are watching. What you read
is the asking Bot's account of the answer rather than the answer verbatim; very long answers are
clipped to keep the relay itself from failing.
Channels say when a Bot is working in them
A channel whose Bot is mid-turn shows a working indicator on its roster avatar — including turns no
browser started, such as a handoff running on the server or a routine. An open conversation also
picks up turns that arrived while nobody here streamed them, so a relayed answer appears without
leaving and coming back.
First sign-in gets an onboarding wizard
A new person lands in a short welcome wizard before the app; everyone who signed in before this
upgrade is stamped as already onboarded by the migration and sees nothing.
Shift+N starts a new chat from anywhere
Bound across the signed-in app, shown under Settings → Keyboard shortcuts, and inert while you
are typing in a field. Handoff work is also picked up the moment it is queued rather than at the
next poll, so an answer's round trip no longer pays up to two seconds per leg.
A Bot's shell can no longer reach the embedded database without a password
In the all-in-one image the cluster was trust-auth on loopback, and the Bot's shell runs in the
same container: it could psql -h 127.0.0.1 -U openbot with no password and read the audit trail,
the policy store, and the credential vault as the instance owner. The cluster now uses
scram-sha-256 with a password generated on first init and kept beside the data, handed to the API
over the container environment. The shell has no way to learn it, so the connection is refused. An
external DATABASE_URL deployment is unaffected.
A live screen that ends says so, instead of freezing the last frame
When a Bot's live screen ended — the computer stopped, or the socket failed — the message explaining
why was drawn only by a component the take-the-wheel view does not mount, so the screen sat frozen on
its last frame with nothing said. The reason is now shown where the live screen is.
A Bot's browser drops the automation flags a person needs gone to sign in
The browser announced itself as automated (navigator.webdriver, the enable-automation switch),
which sites like Google refuse even when a real person has taken the wheel. Those flags are now off
at the source — the browser flag, not a script that patches navigator.webdriver and leaves the
other tells. A headless build still reports HeadlessChrome in its user agent, which only running
headed under a virtual display removes; that heavier change is tracked separately.
An empty model reply, or a run with no question, no longer ends in silence
Two failures on strict OpenAI-compatible providers (z.ai GLM, Anthropic): a follow-up run that
carried only tool deltas and no human turn was refused outright, and a reply with no text and no tool
call ended the run with nothing on screen. A run with no human turn now carries a neutral
continuation, and an empty reply ends on a visible line rather than in silence. OpenAI, which
tolerated both, is unchanged.
Embedded PostgreSQL initialises on a platform volume, and says so when it cannot
EMBEDDED_POSTGRES=on could not create its cluster on a platform whose persistent volume is an ext4
mount — Railway, and by the same mechanism most others — and it failed differently depending on where
the volume was mounted. Neither of the two paths this repo suggested worked, and the two suggestions
disagreed with each other: docs/deployment.md said /var/lib/postgresql/data, the Dockerfile
comment said /var/lib/postgresql.
Mounted at the parent, the mount arrives owned by root, data is not in it, and the image's
build-time chown is hidden underneath — so initdb, which has already dropped to the postgres
user, cannot create the directory. postgres-init now creates and chowns it first, as root, which is
the only step in a position to. This also fixes the plain
docker run -v openbot-data:/var/lib/postgresql case, which relied entirely on that hidden chown.
Mounted directly on the data directory, the mount arrives holding a lost+found, and initdb will
not initialise into a directory with anything in it. The documented mount is now the parent,
/var/lib/postgresql, which leaves data an ordinary subdirectory — what PostgreSQL's own hint
asks for, and what the Dockerfile already said. A volume already mounted at
/var/lib/postgresql/data and working — a Docker named volume, which arrives empty rather than with a
lost+found — keeps working and needs no change.
The failure said nothing useful. api waits on postgres and migrate, so neither started, the
container came up anyway, the platform reported the deploy a success, and the public URL served a
persistent 502 with the real reason visible only in the container log. A data directory that holds no
cluster and is not empty is now refused with a sentence naming the mount to use instead.
Reported by @jerelvelarde with the container logs
for both mount paths, which is what made the two failure modes separable.
Turning on network policies no longer leaves the culler pod open
networkPolicy.enabled rendered policies selecting the server and the computers. The culler
CronJob's pod carries component: culler and was selected by neither, and a pod no policy selects
keeps the cluster default rather than being denied. So the switch fenced the API and the computers
and left open the one pod that wakes every five minutes carrying the API's whole environment,
KEY_ENCRYPTION_KEY and BETTER_AUTH_SECRET included, with a token allowed to create, patch and
delete Sandboxes. It has a third policy now, narrower than the other two, and
networkPolicy.cullerExtraEgress narrows its database egress separately from extraEgress. A
render that leaves any pod unfenced is refused by the chart checks.
A sign-in a site opens in a new window is shown, and can be clicked
A Bot's browser was bound to the page it launched with, and to nothing the site opened afterwards.
Anything arriving in a new window or tab was invisible on the live screen and unreachable by input,
so the popup sign-ins that a person takes the wheel to complete were exactly the ones they could not
complete. Worse than invisible: a click at the place the popup's button was drawn went to the page
underneath it, so a person trying to finish a sign-in could navigate the page the Bot was working on
without seeing either result.
The browser now follows the window the site opens, and returns to the opener when it closes, which is
what a sign-in popup does when it succeeds. A snapshot taken before the change of page is refused
afterwards with the same "take a new snapshot" it already gives after a navigation, so a stale ref
cannot act on the wrong document.
Nothing to configure.
Stopping a Bot's computer stops it, and the person watching is told
A computer somebody stopped came back up on its own about a second later, and reset did the same. The
live screen kept a loop asking for the Bot's current page once a second, asking for a page is what
starts a browser, and nothing tore that loop down when the browser it was showing went away. The same
loop kept the browser marked recently used, so a Bot with somebody watching was also immune to the
idle timeout and came straight back after being closed to stay under the cap on running browsers.
Those last two never involved a request at all, so nothing on the stop path could have covered them.
Two smaller failures went with it. A person who reconnected, leaving their old window open, could
have that old window's typing land in the page the new one was watching, with nothing said to either.
And a window closed while the browser was still starting left a screencast and its loop behind for a
connection that had already gone.
The screen is now held per connection rather than per Bot, so closing one only ever ends its own, and
teardown hangs off the browser closing rather than off the two requests that ask for it. A viewer
whose screen ends is sent a message saying why, whether the computer stopped, was reset, or the
screen was taken over by another window.
Nothing to configure, and no change for a deployment where nobody watches a Bot work. The app does
not yet show that message: it arrives at the browser and is held in state the live screen does not
read, so a person still sees the last frame until they reopen the screen. That half is tracked
separately in #287.