Skip to content

Let a Bot's owner choose its colour and draw it a new face - #914

Draft
jerelvelarde wants to merge 18 commits into
CopilotKit:mainfrom
jerelvelarde:jerel/openbot-bot-look
Draft

jerelvelarde wants to merge 18 commits into
CopilotKit:mainfrom
jerelvelarde:jerel/openbot-bot-look

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Let a Bot's owner choose its colour and draw it a new face

Depends on #913. Its own change is the commits after that one: compare.

The problem

With #913, every Bot is an orb whose colour and face come from a hash of its avatar seed, and nobody can change the seed. agent_profiles.avatar_seed is set when a Bot is created or copied, and the edit route never reads it. A person whose two Bots landed in the same orange, or who wants the Bot that books travel to be blue, has no way to say so. The only lever is a tenant package's avatar_seed, which reaches only the package's own Bots.

Conversation faces have a second gap. ChannelAvatar knows only participant ids and draws each face from the id, which matches the seed only until somebody changes it. A new look would reach the Bot's pages and the Bots list, never a conversation.

The approach

The colour travels in the seed, so nothing is migrated. A seed may start with one of the eight Bot colours and a colon, as moss:k2p9x7aq. The orb draws it in that colour and takes the rest of its look from the hash of the whole seed. Every other seed draws exactly as before, and a test pins the looks of the Bots OpenBot ships with so that stays true. A tenant package's avatar_seed can name a colour the same way. violet: names none, since violet is OpenBot's own. The colours, the parse and the spelling live in shared/bot-look.ts, which the server and the orb both read, so the server never accepts a look the orb would draw differently.

The edit route takes it. PATCH /api/agents/:agentId takes an optional avatarSeed: 1 to 64 letters, digits, hyphens, underscores or colons, with a colour prefix only when a Bot can have that colour. A refusal is a 400 with one sentence, such as "Violet is kept for OpenBot. Pick another colour." Absent leaves the face as it is, so an edit to anything else never restyles a Bot. The store's existing manage check covers it, so only someone who can rename the Bot can change its look, and nobody can change the look of a Bot a tenant package owns. A new look is named on the bot.updated audit row; it is a picture, not a secret. Duplicating a Bot still copies its seed, so a copy keeps its source's look.

The Setup page has a Look section. Beside Identity, for whoever may edit the Bot: its face at 72px and alive, the eight colours as a native radio group named Orange to Grey, and New face, which keeps the colour and draws a new face. A choice saves and shows at once; one that can't be saved goes back and says so. The radios stay live while a choice saves, so the arrow keys can walk the colours, and the newest choice wins. Another colour keeps the face, so going back finds it.

Conversation faces read the seed from the Bots list. useBotSeeds maps each id to the seed in the list the app already holds, so a new look reaches the sidebar, a conversation's header and its messages. Until the list arrives the circle holds no face, rather than one about to change.

An edited Bot keeps its place. The Bots list had no order, so Postgres returned an edited Bot wherever its updated row landed: a new look could move it from first to last, and change which Bot is the default, since that falls back to the first. list() now orders by creation, with the id breaking ties, because a tenant package writes all its Bots in one transaction.

The cost: a conversation's faces now wait on GET /api/agents, and a seed carries meaning in its spelling, so anything that writes one should go through shared/bot-look.ts.

Before After
A Bot's Setup page before A Bot's Setup page with its Look section

What is not covered

  • Eight colours and no others. Violet stays OpenBot's.
  • A Bot the reader's Bots list doesn't hold, such as one hidden from them, falls back to its id in a conversation, so a restyled Bot shows its old face there.
  • New face can't be undone from the screen. The old face comes back only if its seed is sent to the API again.
  • Create takes no seed. A new Bot starts from its id and is restyled afterwards.
  • A tenant package's Bots keep the package's look, and nobody can change it in the app.

Verification

typecheck, lint, format:check and the assertion-shape guard are clean. The full app suite is 2201 pass, 0 fail, against 2190 on #913 and 2129 on upstream main. shared/bot-look.test.ts, server/tests/agent-routes.test.ts and server/tests/bot-lifecycle-audit.test.ts, run on this tip, are 93 pass, 0 fail.

  • shared/bot-look.test.ts: a seed names one of the eight and never violet; the server refuses anything else in a plain sentence; another colour keeps the face.
  • app/tests/bot-look.test.tsx: a colour saves and shows at once and every avatar follows; New face keeps the colour; the newest of several choices wins; a failed save goes back; nobody who can't edit sees the section; a conversation's face waits for the list.
  • app/tests/bot-orb.test.tsx, extended: every Bot that never chose keeps its face; a named colour is honoured, violet and unknown ones are not.
  • server/tests/agent-profile-store.integration.test.ts, extended: an edit changes the look only when it brings one, only for whoever may manage the Bot, and never for a tenant package's Bot; an edited Bot keeps its place in the list.
  • server/tests/agent-routes.test.ts and server/tests/bot-lifecycle-audit.test.ts, extended: the route takes or refuses a look, and the audit row names a new one.

Merge notes

This and #916 both change app/src/components/channels/avatar.tsx and app/tests/user-preferences.test.tsx. Whichever merges second combines the two avatars, and updates app/tests/channel-avatar.test.tsx and the pane-title test in app/tests/computer-pane.test.tsx to hold the Bots list, since a face now waits for it. The combined change is small and already written for whichever lands second.

The last two places still drawing boring-avatars' gradient circles now draw the
orb in the colour the Bot's id picks, so a Bot looks the same in the sidebar,
the conversation header and its profile. Nothing imports boring-avatars any
more, so it leaves the app's dependencies.
…ll when asked

A Bot's orb is a block, so a group conversation's 12px faces no longer sit on
the header's text baseline and drop half out of their circles. It resizes its
canvas when the screen's pixel density changes, so dragging a window to another
screen or zooming no longer leaves it soft, and it follows the reduced-motion
setting live rather than only when it first draws. The changelog and the
stylesheet now describe what this step draws, not what later steps will.
The skins import the orb's state type from agent-orb now that ai-core is gone,
OpenBot's mark keeps its agent-orb class for them, and their tests check the
orb's colour rather than boring-avatars' SVG. The Appearance test's preferences
carry the new skin field.
AgentOrb read "100%" as 100px, so with no Bot yet the header drew a 100px orb
in a 44 or 52px slot. With a Bot, the classes that used to rescale
boring-avatars' SVG squeezed the avatar's circle to 44px below 900px wide while
the orb inside stayed 52px, clipped and off centre. The frame now picks 44 or
52px from the window's width and draws the face at that size, the selectors
that matched only the old SVG are gone here and from OpenDots' Bot rows, and
AgentOrb takes its size only in px and refuses anything else.
Every orb on screen redrew on every animation frame, uncapped, so a sidebar of
conversations kept a few dozen canvases repainting at the screen's full rate
while the app sat idle: a small orb's colour still drifted, so no two frames
were ever the same. A small orb's colour now holds still, an orb skips any
frame that would draw what its canvas already shows in the same theme and at
the same density, and a small orb draws at most about thirty frames a second.
A face at rest with the pointer still now draws only to blink.
…violet

A Bot's face was one of nine colours and nothing else, so a team of four had
about an even chance of two identical faces. In the Bots OpenBot ships with,
three were the same sky blue, and release notes wore violet, OpenBot's own
mark. A Bot is now drawn from its seed: one of the eight colours that aren't
violet, with its colour masses turned, its hue nudged and its eyes spaced by
the same hash, so two Bots in one colour still look apart. BotOrb takes the
seed rather than a colour and draws OpenBot's violet when it has none. The
OpenDots avatar test checked one colour in nine, so it passed or failed on the
hash; it now checks that the seed reaches the orb.
A seed that starts with one of the eight Bot colours and a colon, as
moss:k2p9x7aq, is drawn in that colour; the pattern's turn, the hue
nudge and the eyes still come from the hash of the whole seed. Any other
seed, violet: and unknown colours included, draws exactly as before, and
the default team's looks are pinned so that stays true. The colours, the
parse, the spelling a seed may have and the builders for a new look live
in shared/bot-look.ts, so the server checks a look by the same rules the
orb draws it by.
PATCH /api/agents/:agentId takes an optional avatarSeed and stores it in
the existing agent_profiles.avatar_seed, so no migration. It is checked
by the shared rules the orb draws by: 1 to 64 letters, numbers, hyphens,
underscores or colons, and a colour prefix only when it is one a Bot can
have. A violet: seed or an unknown colour is a 400 with a plain sentence.
Whoever may rename the Bot may restyle it: the store's existing manage
check covers both, so a package's Bots stay as the package drew them. A
new look is named on the bot.updated audit row; an edit without one
leaves the face, and the trail, as they were.
A Bot's Setup page has a Look section beside Identity: the Bot's face,
large and alive, the eight colours as a radio group named Orange to Grey,
and New face, which keeps the colour and draws a new one. A choice saves
at once through the Bot's update, shows at once, and goes back with one
sentence if it can't be saved; the newest of several choices wins, so the
arrow keys can walk the colours while each one saves. Only people who may
edit the Bot see it.

Conversation faces, which know only a Bot's id, now draw the seed the
Bots list holds for it, so a new look reaches the sidebar, a
conversation's header and its messages, not only the Bot's own pages.
They wait for the list rather than draw a face that is about to change.
The OpenDuet skin's conversation rows, which looked seeds up themselves,
pass ids again. Two tests whose fixtures did not answer the Bots list now
do: one counted its requests, and one left a refused lookup retrying in
the app's query client for the next file.
The tenant package reference says what avatar_seed does, that it may
start with one of the eight Bot colours, and that violet: names none.
The changelog says a Bot's owner can now choose its look, how the choice
is stored, and what the API takes.
…defined key

The test now expects the Bots list request the preview row's face makes,
and its expected literal held body: undefined, a key toEqual does not
check, on the assertion-shape allow-list. It compares each request's
method and path and checks every body is absent, and its allow-list entry
goes with the fix.
The Bots list query had no ORDER BY, so Postgres returned rows in whatever
order they lay in, and an update writes a new row version: restyling or
renaming a Bot moved it, and the first Bot in Yours jumped to last.

The list is now ordered by when each Bot was made, with the id breaking a
tie, because a tenant package writes all of its Bots in one transaction and
they share a timestamp. Creation order is what the list showed before
anything was edited. Nothing in the app re-sorts it: the Bots page splits it
into sections in this order, and the app takes the first Bot as the default
when no harness is picked, so an edit could also change that default.

This branch has not been deployed

No deployments
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.

1 participant