dotpals Docs

For contributors

Architecture

How dotpals is put together: where agent activity comes from, how it becomes one feed, and how the pal, the notch and the dashboard show it. The same document is in the repository as docs/ARCHITECTURE.md.

The big picture

  1. Agents report what they do. Each source is turned into the same activity entries by an adapter.
  2. The bridge keeps one feed. It merges entries by id, tracks each session's live state, streams everything to viewers over Server-Sent Events, and keeps a local history in ~/.dotpals.
  3. Viewers render it. The pal, the notch and the dashboard are plain HTML pages. They group entries into requests and chapters in the browser, with the same rules (the story engine).

Everything runs on your computer. Nothing is fetched from, or sent to, the internet.

Components

PartFilesWhat it does
Bridgebridge/server.jsHTTP server on 127.0.0.1. Ingests events; owns the activity log, session states, contexts and approvals; serves the pages and the API; streams /events. startBridge({ port, log, sleepAfter, sleepAfterWaiting }) resolves with the listening server.
Activity modelbridge/activity.jsThe entry shape, createActivityLog() (merge by id, per-session ordering, settle, forget) and text helpers (clip, clipText, relative, folderName, toPatch).
Settingsbridge/config.jsconfig.json: defaults, validation, loadConfig() and saveConfig(patch). Environment variables win.
Adaptersbridge/adapters/*.jsOne module per agent. The registry is index.js; shared config-editing helpers are in setup.js.
Hook forwarderbridge/hook.jsThe command agents run for each hook event. Forwards the JSON to the bridge, always exits 0, and can start the pal.
Context hookbridge/context-hook.jsClaude Code only: on session start and each prompt, fetches GET /api/recap and prints it as additionalContext (Share with your agents).
Claude Code pluginhooks/hooks.json, commands/pals.md, .claude-plugin/Registers hook.js (and context-hook.js) for Claude Code's hook events, and the /dotpals:pals command.
Usagebridge/usage.js, bridge/statusline.jsPlan limits and Claude context-window sizes, read from local files.
Story enginebridge/ui/story.js, bridge/ui/recap.jsPure functions shared by every view (and Node): requests, chapters, flags, plan, headline, toolkit, overlaps, the compact note, Markdown recaps.
Viewsbridge/index.html, notch.html, dashboard.htmlThe pal, the notch, and the dashboard.
Notch logicbridge/ui/notch-state.js, notch-diff.jsPure modules for the notch (they run in Node too): its state machine and its live diff card.
Desktop appdesktop/main.js, preload.cjs, launch.jsElectron: the pal, notch and dashboard windows, tray, shortcut, click-through and drag. Runs the bridge in-process.
CLIbin/dotpals.jssetup, start, dashboard, status, notch, statusline, bridge.
Web componentsrc/*.js, src/index.d.ts<dot-pal>, the characters, custom pals, actions and agent-event helpers. Used by every view and published on its own. See The web component.

The activity entry model

Every adapter produces the same entries (the full shape is in the API reference): an id, session, harness, label and at; a kind (prompt · read · edit · write · run · search · web · agent · mcp · skill · plan · tool · done · error · compact); a title and optional detail; files; a body with command, patch, output or args; a status (running · waiting · ok · failed · stopped · info); timings; the summary on done entries; and plan or task on plan entries.

Merging rules, in createActivityLog().upsert:

  • A patch with a known id merges into the entry. undefined values don't erase fields, body merges key by key, and at keeps its first value.
  • A final status (ok, failed) never regresses. Hooks run as separate processes, so a tool's result can arrive before its start.
  • Each session's entries stay in time order, even when history is backfilled late.
  • settle(session) marks a session's running and waiting entries as stopped when a turn ends.
  • The bridge keeps at most 1500 entries per session in memory.

Viewers group entries into requests (buildTurns in recap.js): a prompt, the steps after it, and the done or error entry that ended it.

Adapters

Every integration is listed in bridge/adapters/index.js as { id, name, via, how, docs, setup, detect(), … }. setup says how it connects:

setupMeaningAgents
pluginInstalled from inside the agentClaude Code
autoNothing to install: dotpals reads the agent's logsCodex
connectdotpals adds a hook or plugin to the agent's configCursor, Gemini CLI, OpenCode, GitHub Copilot CLI
httpThe agent posts to /event itselfAny agent (generic)

Claude Code

  • Live: hooks/hooks.json runs hook.js for SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, Notification, SubagentStart, SubagentStop, PreCompact, Stop, StopFailure and SessionEnd. All are asynchronous except PermissionRequest (see approvals). SessionStart and UserPromptSubmit also run context-hook.js in the foreground, with a 3 s timeout (see Share with your agents).
  • Any /hook or /event body with a string hook_event_name and a session_id (and no ?agent=) is Claude Code. applyHook() folds it into the log, and toAgentState() from src/agent.js gives the pal's state.
  • History: the first hook from a session backfills its whole transcript, with the same entry ids as the hooks. On Stop, the closing message is read from the end of the transcript, retrying once after 1.5 s because the transcript can lag the hook.
  • No hooks: watchClaude() polls ~/.claude/projects/<project>/<session>.jsonl every 1.5 s, picking up transcripts written in the last 3 hours, and gives a session a live pal if its file changed in the last 30 s. Sessions that send hooks are skipped, but their context-window usage is still read here. DOTPALS_CLAUDE_LOGS=0 turns this off.
  • describeTool() maps Claude's tools to kinds, with a -/+ patch for edits and writes, the command for Bash and PowerShell, and mcp__server__tool for MCP.

Codex

  • watchCodex() polls today's and yesterday's folders under ~/.codex/sessions/YYYY/MM/DD/ every second, following logs touched in the last 12 hours. Only sessions active in the last 10 minutes get a live pal.
  • It handles session metadata, prompts, token_count (the context window), tool calls and their outputs, web searches and task_complete (with the last message as the summary). describeCall() covers exec_command, apply_patch (also inside scripts), update_plan, spawn_agent and MCP tools. Failure is guessed from the output.

Cursor, Gemini CLI and GitHub Copilot CLI

  • Connect writes node ".../bridge/hook.js" <id> into the agent's hook config (for Copilot CLI, a file of its own, with the event name as a second argument because its payloads don't name their event). Events arrive at POST /hook?agent=<id> and go to the adapter's apply(event, log).
  • Cursor and Copilot CLI use only hooks that observe, never ones that can approve or block, so their tool calls appear once they've finished. hook.js prints what each agent expects on stdout to carry on ({"continue":true} for Cursor's beforeSubmitPrompt).
  • Gemini's tool events carry no call id, so BeforeTool and AfterTool are paired by tool name and input.

OpenCode

Connect writes a small plugin to OpenCode's plugins folder. The plugin only posts raw facts (prompts, tool calls before and after, tool errors, and session events) to POST /hook?agent=opencode, and it never throws, because an error in tool.execute.before would block the tool. All the interpretation happens in applyOpenCode(), so it can improve without reconnecting. “Send a test event” loads the installed plugin with probe().

Any agent

genericEvent() in server.js handles POST /event: it namespaces activity ids by session, fills defaults for new rows, merges follow-ups with the same id, and drives the pal from state or any event toAgentState() understands. See the event format.

The hook forwarder

node bridge/hook.js                 # Claude Code
node bridge/hook.js <agent>         # another agent's hooks → POST /hook?agent=<agent>
node bridge/hook.js <agent> <event> # payloads that don't name their event (Copilot CLI)
  • Posts to DOTPALS_URL (default http://127.0.0.1:5175/hook) with a short timeout (1 s for Claude Code, 0.7 s for other agents, 125 s for a Claude PermissionRequest) and always exits 0.
  • If the bridge isn't running when a Claude Code session starts or a prompt is sent, it launches the desktop pal (or just the bridge if Electron isn't installed), then retries for a few seconds. DOTPALS_AUTOSTART=0 turns that off, DOTPALS_FLOAT=0 starts only the bridge, and setting DOTPALS_URL disables it too.
  • Commands written into other agents' configs point at ~/.dotpals/app/bridge/hook.js, which outlives npx's temporary folder.

Switching an integration off ({ "agents": { "<id>": false } }) makes the bridge drop its events (it still parses them into a throwaway log, so “Send a test event” keeps working), or stops its log watcher.

How sessions live and sleep

The bridge alone decides when a session is over. Viewers never end a session on a timer of their own.

  • A reaper runs every 30 s. A session that has been quiet for 15 minutes, or 60 minutes if it's waiting (in case you stepped away), is set to sleeping.
  • An agent can end a session itself: Claude Code's SessionEnd, and sessionEnd from Cursor, Gemini CLI and Copilot CLI, map to sleeping.
  • You can dismiss a session with the × on its tab in the pal: POST /api/sessions/<id>/dismiss sets it to sleeping for every viewer, without touching history.
  • sleeping removes the session's state, context and helpers from the bridge. Viewers play the sleeping animation, then remove the pal 6 s later. Any later event brings it back.
  • Separately, the pal page lets a quiet pal doze after 3 minutes. That's only cosmetic; the next event wakes it.
  • Small mode shows only active sessions (working, waiting, or an event in the last 2 minutes; else the most recent one). The session tabs list sessions on the stage or active in the last 30 minutes. The notch shows sessions working or waiting, plus those that finished in the last 90 s.

The story engine

bridge/ui/story.js turns a request's steps into something you can read in five seconds. It's plain rules, not AI: instant, free and the same every time. It runs in the pal, the notch, the dashboard and Node.

stepType(entry)
Classifies each step: explore (reads, searches and look-only commands like ls, git status, cat), change, test, build, install, ship, run, web, agent, skill, mcp, plan, memory, ask, quiet (prompts, turn ends and bookkeeping tools) and tool.
chapters(steps)
Groups a request's steps by type, outcomes first: ask, change, test, build, install, ship, agent, skill, mcp, web, explore, run, tool, scratch, memory. Plan and quiet steps are skipped; changes outside the project become a quiet scratch chapter. Each chapter has a title, a detail, line counts, a status and its steps. Running chapters read in the present tense.
flags(steps, { before })
Things worth a second look. warn: changing a secrets file, recursive deletes, force-pushes, throwing away git changes, dropping tables, piping a download into a shell, sudo or chmod 777, force-stopping programs, and the same command failing 3 times. info: reading a secrets file, publishing a package. before: true words them for something about to happen, for the approval card.
planOf(steps)
The agent's newest plan, from Claude's TodoWrite or TaskCreate and TaskUpdate, Codex's update_plan, Gemini's write_todos or OpenCode's todowrite: { items, done, total, current }.
headline(steps, plan)
The pal's bubble while it works: the plan step or the current chapter, shortened to whole words. It changes only when that changes.
toolkit(entries)
The skills, plugins, MCP tools and helper agents a session used: the “Using” row.
overlaps(entries, since, { within })
Files that two or more sessions changed within 30 minutes of each other, in the last 2 hours by default.
compactNote(entries)
A /compact <instructions> from the session's own record: the goal, what's left on the plan, the files changed and whether the tests are failing. No agent lets another program compact a running session, so the views copy it for you to paste.
story(turn, sessionSteps)
Chapters, de-duplicated flags and the plan, together.

bridge/ui/recap.js has the rest: buildTurns, facts (tallies), sentence (one step as a short sentence), turnMarkdown and recapMarkdown (Copy and Export), summarizeSessions, and agent names and colors.

Usage limits and context windows

Plan usage (bridge/usage.js) is read from local files and cached for 5 s:

  • Codex writes its limits to its own logs, so dotpals reads the newest ones from ~/.codex/sessions.
  • Claude Code gives its limits only to a status line command, and nothing else receives them. That's why dotpals statusline installs bridge/statusline.js as your status line: on each refresh it saves the limits to ~/.dotpals/claude-limits.json and prints a short line, or runs the status line you already had and prints its output.

Context windows are sent as context events. Codex logs both the tokens used and the window size. Claude Code transcripts record tokens but not the size, so the size is known only when the status line reported it for that session, or when usage passes 200k tokens (so it must be a 1M window). Otherwise the views show tokens, not a percentage, and the pal doesn't react, so there are no false alarms.

Helpers

The bridge keeps, per session, a list of the helper agents (subagents) it started, and sends it as a helpers event, the whole list on every change, replayed to new viewers. Three sources feed it, in server.js:

helperFromEntry
Any adapter's kind: 'agent' entries: Claude's Agent tool, Codex's spawn_agent, OpenCode's task… The helper finishes when its entry does.
helperFromHook
Claude Code's SubagentStart and SubagentStop, and the agent_id on a helper's own tool calls. Such a helper is paired with the oldest running Agent call of the same type; from then on its lifecycle hooks decide when it's done (a background helper's tool call returns long before the helper finishes), and doing is its current step as a sentence.
helperFromEvent
The helper field of generic events: { id, name?, task?, state: 'working' | 'done' | 'error', text? }.

Finished helpers stay listed for 60 s, and the list is dropped when the session sleeps. The pal shows them on the running request; the notch on its Now and Story tabs.

Share with your agents

Off unless shareRecap is on. It tells each Claude Code session what other agents did in the same project, so parallel agents don't undo each other's work.

  • hooks/hooks.json runs bridge/context-hook.js in the foreground (3 s timeout) on SessionStart and UserPromptSubmit. It calls GET /api/recap?session=&label=&mode=start|prompt on DOTPALS_BRIDGE (default http://127.0.0.1:5175) with a 1.5 s timeout, and if it gets { text } it prints { hookSpecificOutput: { hookEventName, additionalContext } }. It never starts anything and always exits 0.
  • /api/recap returns {} while the setting is off. Otherwise crossRecap() in story.js writes the note: up to 4 other sessions in the same project folder, active in the last 2 hours, that changed files or ran tests, with the files changed, the last test result, what each was asked, and whether it's working now.
  • mode=start returns the whole note; mode=prompt only when there's news since the last note that session got.

Approve from the pal

  1. Claude Code runs the PermissionRequest hook in the foreground (130 s timeout). hook.js posts it to /hook and waits up to 125 s for the reply.
  2. If approvals are off, nothing is connected to /events, or the request has no tool, the bridge replies {} at once and Claude asks in the terminal.
  3. Otherwise it describes the request (with the same describeTool() as the feed, the command or a clipped patch, and risk flags worded before the fact) and broadcasts an approval event. The pal and the notch show Allow and Deny.
  4. An answer comes back as POST /api/approvals/<id> with x-dotpals: 1. The bridge replies to the hook with { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior } } }.
  5. After approvalWait seconds (default 30, 10 to 120), or if Claude closes the request, it replies {} and Claude asks in the terminal as usual. Every outcome is broadcast so all views clear the card.

It's off by default: while Claude Code waits for a hook, it doesn't show its own prompt, so the terminal looks idle. That's surprising unless you asked for it. More in Privacy and security.

The desktop app

desktop/main.js is a single-instance Electron app: launching it again brings the running one back and passes on its flags.

  • The bridge runs in-process. If the port is taken, the app uses the bridge that's there, and starts its own if that one goes away.
  • Events reach the windows through the main process, which reads /events and forwards each event over IPC. preload.cjs exposes a small window.dotpalsDesktop API; pages are sandboxed with context isolation.
  • The pal window is frameless, transparent and always on top (above full-screen apps, on every workspace), with no taskbar entry. Full view 380×600 (height remembered); small mode 260×290, resized around the bottom-right corner. Closing hides it; Ctrl+Alt+P toggles it.
  • Drag: the page handles the pointer on the pal (under 5 px is a click) and asks the main process to follow the real cursor, always passing the exact intended size, because Windows display scaling otherwise makes a moving window creep bigger.
  • Click-through: in small mode, the page tells the main process whether the cursor is over something solid (the pal's drawn shapes, a bubble, the round bar, an approval card). Over empty space the window ignores the mouse but still forwards moves, so clicks reach your editor. Linux can't forward moves, so there the window stays solid.
  • The notch is a separate window that never takes focus, at the top centre of the primary display. See The notch below.
  • The dashboard is a normal window on /dashboard; outside links open in your browser or editor.
  • The tray, the shortcut and notifications live in the main process; the pal page decides when to notify.

desktop/launch.js finds Electron (DOTPALS_ELECTRON, then the package's node_modules, then ~/.dotpals/node_modules), installs it with --install, and starts the app with ELECTRON_RUN_AS_NODE removed from the environment.

The notch

An island that hangs from the top of the screen. Four pieces work together:

PieceFileJob
State machinebridge/ui/notch-state.jsDecides when the notch hides, peeks, shows its bar or opens, and which alert it shows. Pure: no timers, no DOM.
Diff cardbridge/ui/notch-diff.jsTurns an edit's patch into a few display lines, and a file name into a language chip. Pure.
Pagebridge/notch.htmlFeeds events to the state machine, draws what it says, and asks the app for a window size and for shortcuts.
Windowdesktop/main.js, preload.cjsSizes and places the window, lets clicks through, and sends the cursor, idle time and shortcut presses.

Sizes. derive() returns one of four modes:

hidden
Nothing is running, or you've been away (no keyboard or mouse) for 3 minutes. The window shrinks to a thin, invisible strip (220×5 px) at the top edge, so hovering there can still wake it.
peek
You're hovering that strip, and a small island peeks out. Rest the pointer on it for 600 ms and it opens. Sliding along the top edge (to reach a browser tab, say) restarts that wait, and moving away hides it after 350 ms.
bar
Agents are working. A slim island with a mini pal per agent (up to 4, then “+N”), the current step, the plan step or helper count, and a ring for your highest usage limit. Hover it for 200 ms, or click, to open.
open
The big view, 640 px wide.

The state machine. reduce(state, event, now) returns the next state, derive(state, now) says what to show ({ mode, alert, by, countdown, queued }), and nextWake(state, now) says how many milliseconds until the page should send a tick (a dwell finishes, news runs out, it closes by itself). The page passes the time in, so tests can drive it without waiting.

EventMeaning
{ type: 'agents', running }How many agents are working or waiting for you.
{ type: 'idle', seconds }The computer's idle time. 3 minutes or more means you're away.
{ type: 'pointer', inside, restless? }The pointer moved. inside: over the island (or the hidden strip). restless: it moved more than a few px, which restarts a peek's wait.
{ type: 'click' }A click on the island: it opens.
{ type: 'close' }Esc or the close button. Needs-you alerts are set aside until you open it again.
{ type: 'alert', id, kind, session }An alert: need, done or error.
{ type: 'resolve', id }, { type: 'resolve', session, kind? }An alert is over (answered, or the agent moved on).
{ type: 'tick' }Time passed.
  • Alerts open it by themselves and queue, one at a time: needs-you first, then done and error news, each in the order they came. The header shows how many are waiting (“+2 waiting”).
  • Needs-you alerts (an approval, or an agent waiting for you) stay until they're answered, and show even when you're away. Esc sets one aside until you open the notch again.
  • News (done, error) shows for 5 s or 8 s with a shrinking line, then the notch closes. It waits while you're away, and news older than 15 minutes is dropped. Moving the pointer over news makes it yours: it stays open like one you opened.
  • Opened by you (hover, a peek or a click), it closes 8 s after the pointer leaves, because an open notch covers browser tabs and title bars. With the pointer resting on it, it closes after a minute with no mouse activity. The last stretch (up to 10 s) shows as a shrinking line.
  • After a close, hovering where it was doesn't open it again until the pointer has left.

All the timings are in TIMING at the top of the file: barOpen 200 ms, peekOpen 600 ms, peekLinger 350 ms, doneFor 5 s, errorFor 8 s, autoClose 60 s, afterLeave 8 s, countdown 10 s, away 3 min, staleNews 15 min.

Feeds from the app. The window never takes focus, so it can't see the mouse or the keyboard on its own. desktop/main.js sends it:

  • window:cursor { x, y, width, height }: where the cursor is, relative to the window's content in CSS px (so it can be outside the window), about 30 times a second while it moves. width and height are the content size it was measured against, so the page can hit-test correctly mid-resize. The page works out whether the pointer is over the island, and passes the point to DotPal.pointAt() so the pals' eyes follow the cursor anywhere on screen. The pal window gets the same feed.
  • notch:idle (seconds): powerMonitor.getSystemIdleTime() every 2 s while the notch is on screen.
  • Usage limits over IPC, which the page asks for every 20 s.

preload.cjs exposes these as onCursor(fn) (returns a function that stops listening), onIdle(fn), usage(), notchSize(width, height, island), notchKeys(want), onNotchKey(fn) and platform (to show “Ctrl+Alt+Y” or “⌘⌥Y”).

Window size and click-through. The page sends notch:size with the window size it needs (the island plus room for its shadow and springy overshoot: 26 px each side, 34 px below) and the island's own { w, h }. The window grows at once and shrinks 380 ms later, after the closing animation. Every 33 ms the app checks whether the cursor is over the island, which hangs from the top centre, and calls setIgnoreMouseEvents() so clicks go through everywhere else. A peek reports no island, so it never takes a click: the top edge is where browser tabs are.

Shortcuts. Keys are global shortcuts, held only while needed. The page asks with notchKeys({ escape, approval }) and gets back which ones were registered ({ escape, allow, deny }); presses come back as notch:key.

  • Esc only while the notch is open, the pointer is over it and has moved in the last 8 s, so it never takes Esc from your editor.
  • Ctrl+Alt+Y (Allow) and Ctrl+Alt+N (Deny), ⌘⌥Y and ⌘⌥N on macOS, only while an approval card is showing on the Now tab.
  • They're released the moment they aren't wanted, and whenever the notch hides, reloads, crashes or closes. If another app already holds one, the card doesn't show its key hint.

The page. The open view has the agent in focus as a big pal on the left (the agent an alert is about, else the one you clicked, else the busiest), one card on the right, a column of mini pals for the other agents (with 2 or more), and two tabs:

  • Now: an approval card with Deny and Allow, a “needs you” card, a done card (what you asked, what it said, the files it changed), an error card (the failed step and its error), or, while it works, a live diff of the file it's editing (from diffOf(): the newest change, up to 8 lines, the newest added line typing in) or a checklist of its steps and what's left on its plan. Below that: plan and context bars, helpers and usage limits.
  • Story: Today (requests, files changed, commands, agent time, with Copy today), the plan, helpers, the context bar with Copy /compact (from 60%), the “Using” row, the note when another agent changed the same file, and the last 3 requests as chapters you can expand. It's built from the story engine, like the pal's Summary.

The tab you pick is remembered separately for busy and idle agents (dotpals.notch.tabs in local storage). diffOf() reads all three patch shapes the adapters produce: -old/+new lines (Claude Code, Cursor, Gemini CLI, OpenCode), Codex's apply_patch, and unified diffs.

Motion. The island springs a little past its size when it grows (500 ms) and shrinks without a bounce (340 ms). The view that leaves fades out with a blur and the new one fades in, and the mini pals slide between the bar and the column. With prefers-reduced-motion, all of that is turned off.

When it shows. Mode auto (whenever the pal is hidden, the default), always or off, set from the tray or dotpals notch.

The web component

src/ is the <dot-pal> element. It has no dependencies and no build step. src/index.d.ts is the source of truth for its public API.

FileWhat it has
element.jsThe element: states, moods, faces, reactions, the bubble, particles and blending.
characters.jsThe eight built-in characters and registerCharacter().
custom.jsThe pal builder: buildCharacter(), registerCustom(), cleanCustom(). Works in Node.
actions.jsOne-shot actions for play() and registerAction().
agent.jstoAgentState(), connectAgent() and agentHandler().
  • Layers. Inside the shadow root: a glow (--dp-glow), then .dp-idle (the looping idle or mood animation, in CSS), .dp-pose (the lean toward the cursor and the dizzy sway) and .dp-actor (one-shot actions, with the Web Animations API), around the SVG. Particles are drawn in a separate SVG layer and the bubble sits on top. A ledge clips everything below the bottom edge, so a pal can rise up from below without adding scrollbars.
  • Faces. A character that says where its eyes are (eyes: { at, r }) gets expression eyes: eyeShape() draws happy arcs, closed lids, wide eyes, ×, spirals, hearts or sparkle-stars at those points, and the character's own eyes (.dp-eyes, or else .dp-blink) hide meanwhile. MOOD_EYES picks them for moods (happy → happy, sleepy → closed, surprised and waiting → wide; the error state shows ×). emote() faces win over the mood for a moment. The swap happens behind a quick blink. A character without eye anchors just squints its own eyes, as before.
  • Blending. Before a mood, idle loop or action changes, the element measures the current transform and plays a short additive animation from the old pose into the new one. So nothing snaps, even when an action interrupts another.
  • Timelines. Faces and reactions run as small scripts on one animation-frame loop. A newer script on the same channel ends the old one, and the old one's faces are still cleared.
  • Reactions. Hover: a blink, a squish and slightly bigger eyes. Rest the mouse on it for 2 s: heart eyes (at most every 20 s). Click: its tap action, a “hey” face and a dotpal-poke event. Three clicks within 1.2 s: dizzy. static turns these off.
  • State entry moves. waiting hops, then its loop bounces; error jitters; done jumps and throws sparkles. After 90 s of working or thinking, a sweat drop now and then.
  • Tiny. Under 48 px (from a pixel size, or a ResizeObserver for other lengths) the element sets the tiny attribute: no fur, no glow, no particles and no lean, bigger eyes and mouth, deeper breathing and more glancing, so an avatar still reads as alive. The notch's mini pals are tiny.
  • One pointer listener for every pal on the page, fanned out once per frame. DotPal.pointAt(x, y) feeds it from outside, which is how the desktop app's cursor feed reaches the pals.
  • Reduced motion. With prefers-reduced-motion: reduce, faces and blinks still change, but idle loops, eye wandering, the lean, particles and the pal's own moves (entry moves, hover and click moves, the hello and the dizzy spin) are skipped.

Persistence

Path in ~/.dotpalsWritten byContents
config.jsonbridge/config.jsOnly known keys with valid values.
history.jsonthe bridge{ version: 1, entries }: the last historyDays days, at most 5000 entries, written 2 s after the last change through a temporary file and a rename. On load, entries still running become stopped.
claude-limits.jsonbridge/statusline.jsClaude's limits, latest context window, model name, and window sizes for the newest 30 sessions.
statusline.jsondotpals statuslineThe status line you had before.
app/dotpals setupThe installed copy that hook commands point at.
node_modules/launch.js --installThe Electron runtime.

The desktop app keeps its window settings in window.json in Electron's user-data folder; the pal page keeps a few preferences in local storage, and the notch the tab you last chose; and Connect and dotpals statusline keep <file>.dotpals-backup copies. See Where files live.

Security model

  • Local only: the bridge listens on 127.0.0.1.
  • Host check: everything except POST /hook and POST /event must be addressed to 127.0.0.1, localhost or [::1], or gets 421. This stops DNS-rebinding pages.
  • Changes need x-dotpals: 1: browsers send a custom header cross-origin only after a CORS preflight, which the bridge never approves (it sends no CORS headers), so other websites can't change settings, connect agents, dismiss sessions or answer approvals, and can't read responses.
  • Event ingestion is open to local processes: they can add to the feed, not read it back.
  • Static files are served only from src/, bridge/ and desktop/.
  • The desktop app uses sandboxed, context-isolated pages and opens only web, vscode: and cursor: links outside the app.
  • Never in the agent's way: hooks always exit 0 quickly; observing hooks only; approvals opt-in with a fallback.
  • Careful with other tools' files: back up, merge, write atomically, refuse to touch a file that can't be parsed, and remove only what dotpals added.

Testing

npm test    # node --test: every test/*.test.js, no dependencies needed
  • Tests start real bridges on random ports with startBridge({ port, log: () => {} }), and keep away from your files with DOTPALS_HOME (a temporary folder), DOTPALS_CODEX=0, DOTPALS_CLAUDE_LOGS=0, DOTPALS_HISTORY=0 and the DOTPALS_*_DIR variables.
  • Adapter tests feed sample events into the apply…() functions; connect tests check that config files are merged, backed up and restored. test/server.test.js covers events, static paths, sleeping, dismissing and approvals; test/story.test.js the story engine.
  • test/notch-state.test.js drives the notch's state machine with made-up times (no waiting), and checks diffOf() and language(). test/element.test.js checks the pal's pure parts in Node: expression eyes, particles, gaze, every character's eye anchors and the new actions.
  • CI checks every file's syntax and runs the tests on Node 20 and 22 on Linux, Windows and macOS, without Electron.

For the UI, run npm run float (the desktop pal), or npm run bridge and open http://127.0.0.1:5175/ and /dashboard. npm run dev serves the web component playground on port 5173.

Adding an adapter

  1. Create bridge/adapters/<id>.js with a default export. Start from cursor.js (hooks), copilot.js (a hooks file of its own), opencode.js (a plugin) or codex.js (a session log).
    export default {
      id: 'myagent',                 // lowercase: [a-z][a-z0-9-]*
      name: 'My Agent',
      via: 'Hooks (~/.myagent/hooks.json)',
      how: 'One plain sentence for the Agents page.',
      docs: 'https://…',
      setup: 'connect',              // 'plugin' | 'auto' | 'connect' | 'http'
      file: () => …,                 // the config file Connect edits
      detect: () => ({ found, where }),
      connected: () => true,
      connect() { return { file, backup, command }; },
      disconnect() { return { file }; },
      command: () => '…',            // the command as installed
      sample: (token) => ({ … }),    // a payload for "Send a test event" (token is the session id)
      apply(event, log) {            // events from POST /hook?agent=myagent
        return { entries, session, label, state };
      },
    };
    A log-following agent provides watch(log, { emit, state, context }), returning a stop function, instead of apply. A plugin can provide probe({ url, token }) instead of command and sample.
  2. Use the helpers in setup.js: hookCommand(id), isOurs(command, id), readJson (throws on a file it can't parse, so it's left alone), backup, writeJson (atomic), writeOwnFile and removeOwnFile.
  3. Produce standard entries. Prefix session ids with your id (myagent:<id>) and entry ids with the session. Use clip, clipText, relative, folderName and toPatch. Report plans as kind: 'plan' with a plan array, and call log.settle(session) when a turn ends.
  4. Register it: add it to ADAPTERS in bridge/adapters/index.js, its id to AGENT_IDS in bridge/config.js (so it can be switched off), and a name to NAMES in bridge/ui/recap.js. If the agent expects something on stdout from a hook, add it to REPLY in bridge/hook.js.
  5. Test it: let tests move its folder with a DOTPALS_<ID>_DIR variable, and add test/<id>.test.js covering apply, connect and disconnect.
  6. Document it in the README and on the Agents page (site/guide/agents.html).

Edit this page on GitHub