Animated pixel-art avatars for agents, built for the Frontier Tower demo: each persona is an OpenCode-hosted agent with a 32×32 truecolor face that thinks, lip-syncs its streamed reply, and steers its own expression as it talks. Run as a mesh, the faces coordinate as lateral peers in one Cotal space: you watch them talk to each other. The demo runs on a live display on Frontier Tower's 10th floor, the Immersive Commons floor.
There are two front-ends onto the same live mesh: a browser studio (tools/studio.mjs)
and a tmux wall (mesh-wall.sh). Both spawn real agents; nothing here is scripted.
One command brings up the whole thing (the mesh, the agents, and the web UI):
node tools/studio.mjs # the full panel, cast from cotal.yaml
node tools/studio.mjs -f my-panel.yaml # a smaller cast (your own manifest)
SPACE=demo PORT=4097 node tools/studio.mjs
# then open http://127.0.0.1:4097/What happens end to end, all real:
- it ensures a Cotal mesh is up (starts
cotal up --openif one isn't); - it joins as an operator endpoint: "you", the human seat in the room;
- it spawns each roster member as a real headless mesh agent (OpenCode + the cotal plugin);
- the page renders each agent's animated face driven by that agent's live OpenCode stream,
the authoritative mesh transcript (what the operator endpoint actually receives on
#general), and a prompt box.
The bottom box broadcasts to #general (the whole panel); each face also has its own input to
DM that one agent privately: only it wakes, and it replies straight back to you. Type → the
operator posts → every (or one) agent's connector turns it into an OpenCode turn → they reply and
coordinate over the mesh → you watch their faces talk. A face's status dot tracks its real activity
(thinking / working / speaking); a tile flashes when that agent sends on the mesh. The studio
fresh-boots the space's history on start, so each run begins clean. Ctrl-C tears it all down.
Requires opencode (opencode auth login) and a built repo (pnpm build at the repo root).
The studio needs an --open mesh (it joins bare, as the operator). If something is already on
the default port that rejects it (e.g. an existing auth mesh on :4222), it stops with a clear
message; run it on its own free port + space instead:
SPACE=frontier COTAL_SERVERS=nats://127.0.0.1:4299 node tools/studio.mjs sven david garryRequirements: Node ≥ 20, OpenCode (run opencode auth login once;
the personas default to opencode-go/glm-5.1), and tmux. The mesh's nats-server is
bundled by the CLI, so there's nothing else to install.
# the whole demo in one command: start the mesh, a grid of mesh faces, and the console
./mesh-wall.sh # curated roster + console
./mesh-wall.sh sven david # pick agents (agent-file basenames)
./mesh-wall.sh all # every agent (capped at 9 panes)
./mesh-wall.sh --fresh # wipe the space's chat history first, then start (clean slate)
./mesh-wall.sh --stop # tear it all down + wipe the chat history (clean restart)Standard layout: the face grid is on the left, the console (live mesh traffic) is the
pane on the right, in one tmux window. Each face is a real Cotal mesh peer; type into one and
the others can see and answer it on the shared space. Switch focus with the mouse or Ctrl-b ←/→. Override the model, space, or console width with MODEL=opencode/<free-model>,
SPACE=demo, or CONSOLE_WIDTH=40% ./mesh-wall.sh.
Built to run on a public monitor where people walk up and try it, like the demo's live display
on Frontier Tower's 10th floor, the Immersive Commons floor. The mesh wall shows a Cotal signage strip
across the top (wordmark + tagline + a QR to cotal.ai so passers-by can
open the site on their phone), plus a persistent Cotal · cotal.ai status bar along the bottom.
The browser wall (tools/serve-wall.mjs) carries the same QR in its header and footer.
The terminal QR is an inverted glow (bright pixels on the dark theme, no white card). It's a mildly non-standard inverted code: modern phones (iOS Camera, Google Lens) scan it, and the browser wall renders a dark-on-light QR that scans on everything, so that's the reliable path.
node tools/brand-banner.mjs --variant 1|2|3 # Card / Bar / Hero layouts
node tools/brand-banner.mjs --qr-color cyan|blue|white|magenta|#hex # glow colour (default blue)
node tools/brand-banner.mjs --image # native pixel-image QR (Ghostty/kitty, no tmux)
NO_BANNER=1 ./mesh-wall.sh # hide the top signage strip
BANNER_VARIANT=3 BANNER_HEIGHT=24 ./mesh-wall.sh # Hero strip (taller; the QR needs ~16+ rows)The QR is pre-generated, not encoded at runtime: qr-cotal.mjs holds the static matrix (rendered by
both walls: terminal half-blocks and a browser canvas) and tools/brand-banner.mjs /
tools/tmux-brand.sh draw the strip and status bar. To point it at a different URL, regenerate the
matrix per the note in qr-cotal.mjs.
For a public monitor, run the live studio (above) full-screen; it self-recovers and keeps the panel talking. Nudge it occasionally (a question in the prompt box) to keep the conversation moving. The studio carries the same Cotal branding as the tmux wall's signage strip.
Each face is its own OpenCode chat, with no mesh and no shared space:
# one face, one live agent
opencode serve --port 4096
node face-term.mjs --persona sven
# no server? scripted preview
node face-term.mjs --demo
# a wall of them in the terminal (one shared server, one session per pane)
./face-wall.sh # every persona, capped at 9
./face-wall.sh ray sven garry
# the same wall in the browser (serves web/ + proxies the opencode API, no CORS)
node tools/serve-wall.mjs # then open the printed URLface-term.mjs flags: --persona <key> (--list prints all), --server, --model <provider/id>, --session <id> to attach to an existing session, --password for
OPENCODE_SERVER_PASSWORD-protected servers, --dump to print the grid as ASCII.
tools/studio.mjs: the browser studio; it ensures the mesh, joins it as the operator endpoint, spawns the roster as headless mesh agents (the connector'sCOTAL_SERVE_HEADLESSmode), proxies each agent's live OpenCode stream, and servesweb/studio.html.web/studio.html: the studio UI, a grid of live<cotal-face>tiles + the mesh transcript + a prompt box. Drives each face from its agent's real OpenCode SSE (viacotal-opencode.js) and the transcript from the operator endpoint's feed.mesh-wall.sh: the tmux one-command launcher; starts the mesh, a tmux grid of mesh faces (onemesh-face.shper agent), and the console.mesh-face.sh+mesh-face.mjs: one mesh agent. The.mjslauncher starts anopencode servewith the@cotal-ai/connector-opencodeplugin + an agent file (so it joins the mesh and creates a session), reads that session's id (the plugin prints[cotal-session] <id>), and attaches the face. The OpenCode connector is face-agnostic; this launcher owns the viewer attach, so face rendering never leaks into shared code.face-plugin.mjs: example-local OpenCode plugin registering theface_<mood>expression tools. A mesh face calls them to drive its avatar, keeping itscotal_*messages clean on the wire.face-term.mjs: the terminal face (half-block renderer, zero deps). Connects to an OpenCode server, maps its SSE events to the face: assistant text drives the lip-sync, whileface_<mood>tool calls (mesh) and inline[[face:X]]tags (standalone direct chat) drive the expression.personas.mjs: the pixel data, one entry per persona (single source for the terminal face).face-wall.sh: tmux grid of standalone faces, one direct chat session per pane.agents/: the persona definitions (OpenCode agent files): digital twins of ten Frontier Tower panelists, each tuned to coordinate as a lateral peer on a Cotal mesh. Theface:frontmatter maps an agent to its persona key where the names differ (steve→jobs, elon→musk, rayan→ray).research/: the public-record research the agent files are distilled from.web/: the same engine as a<cotal-face>custom element (cotal-face.js, drawing its personas straight frompersonas.mjs), a livewall.html(a browser twin of the tmux wall), and a userscript that overlays a face on OpenCode's web UI (its CSP blocks plain script injection).tools/: persona authoring;img2rows.mjsroughs a reference image into rows+palette,render-png.mjsrenders a contact sheet for review,face-template.mjsis the copy-me reference face.preview.htmlshows every persona straight frompersonas.mjs.
Append an entry to personas.mjs (rows, colors, glow, mouths, expr, eyes, lines) and it's
immediately available everywhere: --persona, --list, the walls, and the browser
(web/cotal-face.js imports personas.mjs, so no manual sync). Copy tools/face-template.mjs
to start from a known-good face; FACE-DESIGN.md documents the grid zones,
the color keys, the eye recipe, and the expression/viseme conventions.