merkhain

Web UI: My Memory and Relay Administration

The same binary serves two browser surfaces from embedded static files under /ui/. No Node.js, Electron or extra download is needed at runtime.

Surface Served by Sign-in Can do
My Memory merkhain serve local (default http://127.0.0.1:7781/ui/) one-time launch link from merkhain ui browse and search memory, sessions and provenance; processing health; edit Memory & recall settings; create, join and manage teams; connect your other computers; share projects
Relay Administration merkhain serve remote (<remote_public_origin>/ui/) the relay operator token read relay metadata (public team labels, computers, invitation status, storage); issue and revoke permissions to create a team

Both products follow your system's light or dark setting until you use Switch to light or Switch to dark at the foot of the sidebar; this browser then remembers your choice. My Memory shows This computer and, once this computer has a sharing identity, its device name. Relay Administration shows the server address the browser reached. The navigation of one product never links to the other.

Keys and memory plaintext stay in the local service. The browser never holds pool keys, device keys or the local API token. Relay Administration never sees memory content or team names.

My Memory: merkhain ui

Start the service (merkhain serve local, or its user service, see Install), then:

merkhain ui                # opens a signed-in browser tab
merkhain ui --no-open      # prints the link instead (stdout only); paste it into a browser
merkhain ui --timeout 10s  # how long to wait for the service (default 5s)
  • The link looks like http://127.0.0.1:7781/ui/#launch=bzu_…. It works once and expires after 60 seconds. The page removes it from the address bar before using it. Do not share it.
  • The link never appears in a process's arguments, where other accounts on the computer could read it. On macOS merkhain ui hands it to the default browser through osascript on a pipe. On Linux and Windows it opens a private redirect page (owner-only, in the user cache directory under merkhain/ui-launch/) that forwards to the link. A sandboxed browser that cannot read that directory (for example a snap or Flatpak browser) shows a missing file: use merkhain ui --no-open and paste the link instead.
  • If the service is not running, merkhain ui exits 75 (unavailable). Start serve local and run it again.
  • A browser session lasts 8 hours and ends on Sign out or when serve local restarts. While you use the page in its last hour (click, type or scroll), it renews itself for another 8 hours, up to 7 days after you signed in; a tab left idle still ends on time. To sign in again, run merkhain ui again, or paste a fresh --no-open link into the Launch link field on the sign-in page.
  • Only the configured local_listen address is accepted as the Host. localhost:7781 does not work when the service listens on 127.0.0.1:7781, and the reverse is also true. Use the exact address that merkhain ui prints. Cross-site requests, other origins and DNS-rebound host names are refused.

Headless machines and SSH

--no-open never starts a browser, so it works over SSH or on a server without a desktop. The UI binds to loopback only. Forward the port, then open the printed link on your own computer:

ssh -L 7781:127.0.0.1:7781 devbox      # keep this open
ssh devbox merkhain ui --no-open      # copy the printed link

The forwarded port must be the same number as local_listen. The Host header has to match, so do not remap it to another local port.

Screens

The sign-in page is titled Open My Memory. Its first action copies the public merkhain ui command. To keep a pending invitation or handoff in the same page, follow the manual steps and paste a fresh merkhain ui --no-open link. Expired or used links need a new launch; an unavailable service needs merkhain serve local and Try again. No private navigation appears before authentication.

After sign-in the sidebar contains Home, Browse, Insights, Sessions, Prompts, Processing, Sharing, Settings. On narrow screens, Menu opens a drawer: Tab stays inside, Escape or Close menu returns focus to Menu, and choosing a destination closes the drawer and focuses the new content. Skip to content is the first tab stop; Sign out is the second. Settings, Insights and detail pages keep their useful local navigation.

The destinations are:

  • Home (/ui/) and Browse (/ui/memory): the home has a search field, one card per project (memory and session counts, last activity) and the most recently learned memories, filtered by kind. Search results (/ui/memory) filter by kind, project, source, agent, session and date. Nonsecret filters stay in the address; private search text stays in page memory and is never stored in the URL or browser storage. Cards are labelled Local or Shared · <team>. A memory opens in a dialog over the page it was opened from (/ui/memory/m/<id>): provenance (project, origin, agent, session, worktree, branch, commit; person and computer IDs under Details) and the source events when the original conversation is still on this computer. Escape closes it and returns to the same card. Memory text is always rendered as text, never as markup.
  • Insights (/ui/insights): Overview, Usefulness & reuse, Retrieval health, Method comparison and Usage history, with local navigation that preserves the reporting window.
  • Sessions (/ui/activity): processing stages and captured agent sessions with their memory processing, events and extracted memories. Existing Activity URLs remain supported; the list is titled Sessions and each detail is titled Session.
  • Prompts (/ui/activity/prompts): captured prompts and their detail pages.
  • Processing (/ui/activity/processing): whether recent work is available as memory, what is delayed and what you can do. See Processing troubleshooting. The sidebar owns these three destinations; there is no duplicate Activity tab strip on list or detail pages.
  • Sharing (/ui/sharing): the goals Create a team, Join a team and Connect another computer, then Personal sync, teams and shared projects. See Sharing in the browser.
  • Settings (/ui/settings): Overview | Agent integrations | Memory & recall | Imports | Projects | Service & relays | Computer & session. Overview offers task links and four compact setup summaries. Agent integrations (/ui/settings/agents) checks Claude Code hooks, Codex hooks, the OpenCode plugin and the pi extension, followed by expandable instructions and safe install commands. Missing unused agents do not flag navigation; broken existing integrations do. Older services' absent rows say Not checked. Checks use the service's environment and show checked locations; command-guided setup respects unmanaged files, shared skills and returned follow-up notes. Claude Code and Codex also have Install/Update buttons, automatic-update notes and a restart warning when the executable changed (see Keeping hooks up to date). OpenCode and pi remain command-guided. Links such as /ui/settings/agents/opencode expand and focus the requested integration. Memory & recall (/ui/settings/injection) edits this computer's extraction provider and provider-specific model, loopback endpoint, prompt/search context, comparison, embeddings and judging. Each field shows effective, file and default values with source and environment locks. Reset removes its file override. Daily judge budget zero is unlimited; the service reports effective concurrency. Older services keep an honest read-only view. Drafts stay in page memory across internal app navigation in the authenticated session, never in URLs, history state or browser storage. Review shows a diff and data implications before Save; Keep editing retains the draft and Discard restores the latest confirmed values. Reload/close warns while dirty even while browsing another page; sign-out clears the draft. External edits require deliberate reconciliation. A lost save response triggers a re-read before any retry. Saved configuration can be only partially applied: runtime status identifies components still running earlier configuration and says when an unusable provider pauses extraction instead, and applied/ ready does not prove CLI health. Search-context changes update existing default-location Claude Code and Codex hooks automatically; other installations use the displayed commands. Imports (/ui/settings/imports) retains preview/confirmation and import history. Projects (/ui/settings/projects) retains paginated repository mappings, filtering of loaded projects and copyable CLI commands. Service & relays (/ui/settings/service) shows processing, background-service and sync status. Shared membership, sync and project mappings belong in Sharing; managed Personal sync uses hosting controls and has no standalone admin console. Standalone relay administration requires a separate operator address and authentication; pool URLs do not establish console availability. Computer & session (/ui/settings/computer) shows fixed computer naming, identity, CLI-only recovery export and session expiry. It never requests a recovery passphrase. Usage history stays at /ui/insights/settings, with its separate database-backed API; /ui/settings/usage links there. Old #settings-* section aliases and prototype section hashes remain supported. Settings works without a sharing identity and never shows a configuration/key path or credential. Redacted endpoints remain hidden and are never sent back automatically; only explicit replacement or reset changes one. Existing remote endpoints are read-only. Extraction processes local captures, including pending/retried backlog when the provider changes; recall/judging may include locally decrypted shared memories. There are no per-pool model controls.

Old addresses keep working and are rewritten in the address bar: /ui/queues → /ui/activity/processing, /ui/memory/sessions → /ui/activity, /ui/memory/sessions/<id> → /ui/activity/sessions/<id>.

Relay Administration (operator sign-in)

The relay serves Relay Administration at <remote_public_origin>/ui/. Set the origin to the exact https origin that browsers use. Login stays disabled behind native TLS or remote_trusted_proxy until the origin is set:

MERKHAIN_REMOTE_PUBLIC_ORIGIN=https://relay.example.com merkhain serve remote   # or "remote_public_origin" in config.json
  • The page is titled Sign in to Relay Administration. Sign in with the content of relay-operator-token from the relay's config directory. The token is posted once, and the browser then holds an HttpOnly, SameSite=Strict session cookie for 8 hours, renewed while you use the console in its last hour, up to 7 days after sign-in. Over https the cookie is Secure and named __Host-merkhain_ui_session.

  • Only the configured origin can sign in. Another host name for the same socket, or plain HTTP that bypasses the proxy, is refused. An http:// origin is accepted only for a plain-HTTP relay on loopback, for local development.

  • Sessions end on sign out and on every relay restart. Rotating the operator token means deleting the file and restarting the relay, which also ends every browser session.

  • Relay Administration cannot create teams or change membership itself. Logins are rate limited: wait for the Retry-After time.

  • After sign-in the navigation is Overview (/ui/, health), Teams (/ui/teams), Creation permissions (/ui/creation-permissions, Allow someone to create a team and the issued permissions, with revoke) and Settings (/ui/settings, read-only). The old addresses /ui/relay and /ui/relay/teams/<pool> are rewritten to /ui/ and /ui/teams/<pool>.

Details and deployment: Relay.

Local confirmation handoff

Relay Administration and My Memory never call each other. They hand over through a link whose secret is only in the URL fragment, which browsers do not send to servers:

  1. In Relay Administration, the operator opens Creation permissions, enters a public label and clicks Allow someone to create a team. Review the label, relay and one-use scope, then click Issue permission. The operator does not need to know the recipient's local address.
  2. The page shows the Team creation link panel: the expiry (relative and absolute), "Works once", instructions for the recipient, Copy link, Open creation link and Done. The link points at the relay's own recipient page, <relay origin>/ui/create-team#v=1&action=create-team&relay=<origin>&kind=team&label=…&grant=bzg_…; it is never shown as text. Send it privately to the person who will own the team. The permission works once and expires after 10 minutes. The page forgets it once the link is opened or Done is pressed.
  3. The recipient opens the link on the computer that will own the team. The relay page is public (no operator sign-in), clears the fragment before anything else runs, makes no requests and shows the relay and the suggested team name. Open My Memory opens http://127.0.0.1:7781/ui/sharing/handoff#… in a new tab; if My Memory runs at another address there, set it under My Memory uses a different address (loopback only, kept on this page only). Copy permission copies the link again, and Dismiss forgets it. Opening My Memory creates nothing, and the page keeps the permission for another try while it stays open. A reload loses it: open the original link again. Instead of Open My Memory, the recipient can open My Memory, choose Create a team and paste the link; the field also accepts an older http://127.0.0.1:7781/ui/sharing/handoff#… link.
  4. My Memory clears the fragment before anything else runs. If the browser is not signed in, the sign-in page says "A team creation link is waiting. Sign in on this page to continue. Opening a new window loses it." Sign in with merkhain ui --no-open and paste the link it prints into the same page.
  5. My Memory shows Review the new team with the relay origin and the owner, and suggests the operator's public label as the team name. Nothing is sent until the person clicks Create team. The team's keys are created locally. Cancel forgets the link. A legacy link for a personal pool shows Review Personal sync instead.
  6. The outcome page links to the team by the pool id from the response:
    • Team created. The team is registered with the relay.
    • Team created on this computer. Registration with the relay is not confirmed (HTTP 202; the error code is under Details). The team and its keys are stored locally. Nothing retries this on its own, because the registration needs the link, which only the page holds. Finish registration sends the same request again; it registers the stored team and does not create another one. After a reload, open the original link again (or a new one) and create the team with the same name.
    • We could not confirm whether the team was created. The local service did not answer, or its answer does not say whether the team was stored. Check Sharing for a team on that relay that you did not have before. There is no retry button, because the page cannot tell whether the first attempt arrived.
    • This team creation link cannot be used. It expired or was already used. The local service stores the team before the relay refuses the link, so the team can stay listed as "Not yet registered with the relay". A new team creation link used with the same name registers it.

The link lives only in the memory of the page that opened it, never in browser storage. While it waits (or while a team waits for Finish registration), a banner on every My Memory page leads back to it, through navigation and a sign-in on the same page. A reload, a new tab or a browser restart loses it. sharing/handoff then says "There is no pending link in this page." and asks you to open the original link again. Without an unlocked identity nothing is sent. The page shows the service's hint and keeps the link: after restarting the service, sign in again and open the original link again. If the local service does not answer, Try again keeps the link too.

Invite, cancel and remove on a Relay Administration team page work the same way: they are links to the owner's My Memory, where the signed-in owner confirms the action. An expired or already used link is reported on the outcome page, and the person stays signed in. Ask the operator for a new one.

Following a link from an https Relay Administration page to the http loopback My Memory is an ordinary top-level navigation. The local daemon does not need to trust the relay's certificate to show the confirmation page, but it must reach the relay over trusted TLS to register the team.

Sharing in the browser

All sharing actions run in the local service with the computer's keys, and the browser only asks for confirmation. See Sharing for the model. Sharing (/ui/sharing) starts with two paths side by side, each showing its current state (your Personal sync and its computers; your teams), so pairing your own computer never sits next to inviting another person:

  • Sync my computers (only you): Sync my computers with Tailscale while no Personal sync exists, and Connect another computer.
  • Share with a team (other people): Create a team and Join a team.

Shared projects and enrollments in progress follow only when there are any. The goal pages:

  • Create a team (/ui/sharing/create): paste or open a team creation link from a relay operator, review it, then click Create team (see Local confirmation handoff).
  • Join a team (/ui/sharing/join): open the invitation link a teammate sent you, or enter the relay address and the invitation code. A pairing code is refused here before anything is sent; it belongs under Connect another computer.
  • Connect another computer (/ui/sharing/connect): two paths. Pair a computer from this one lets you choose Personal sync or a team, says that the connected computer acts as you in that space only (with its own keys, and nothing of your other spaces or unshared memory), then creates a pairing code: never a link and never an invitation. A new computer redeems it with merkhain device join --relay <relay> (it asks for the code), then restarts merkhain serve local. A computer already set up as you can use I have a pairing code instead. See Sharing.

Memory works without a sharing identity, so the Sharing page itself never asks for one. A goal page that needs it explains that sharing needs an identity for this computer and shows the service's hint (for example merkhain identity init) instead of a form.

What the two paths show:

  • Personal sync: your own computers, when a personal pool exists. Without one, Sync my computers with Tailscale sets it up in the browser: one computer hosts, the others connect with its sync address and a pairing code (Sharing). With a relay you run yourself, merkhain pool create still works (Sharing).
  • Teams: name, your role, relay, sync status, and how many people and computers each has.
  • Shared projects: one line per project and team, for example Project merkhain → Platform team · Future memories. Sharing a project sends memories learned from then on. Existing history is shared only with the separate Share existing history… action on the team page, after a confirmation; this cannot be undone. After it succeeds, the line reads · Including existing history until the page is reloaded. No API reports an earlier share, so after a reload the line makes no claim about history either way.

On a team page (Teams → <team>):

  • Invite a teammate first says what the invitation gives: your teammate joins as a separate person with their own computer and keys, and can read everything the team has shared so far and everything it shares later. Create invitation then shows an invitation link once, built for your teammate's merkhain address (default http://127.0.0.1:7781; change it if they run merkhain on another local port). The relay and the single-use code (bz1-…) ride in the link's fragment, which browsers never send to a server. Under Can't use the link? are the code and merkhain member join --relay <relay>, which asks for the code. Everything is cleared when you press Done or when it expires. The inviting computer approves automatically while merkhain runs there: right away, on its next service tick, or on restart.
  • Your teammate opens the link on their own computer while merkhain runs there. If it asks them to sign in, they run merkhain ui --no-open and paste the link it prints into the same page; the invitation waits in that page (a reload or a new window loses it). Nothing is sent until they press Join team. A link that holds a pairing code, or a mistyped code, is refused before anything is sent.
  • Your other computers links to Connect another computer with this team chosen. Pairing codes are made there, never under Invite a teammate.
  • Invitations and pairing codes lists pending and finished ones with what each is waiting for. Check now asks the relay once; Cancel stops an unused code. Neither runs on its own: the page only reads while it refreshes. A shown invitation or pairing code disappears once it is used, cancelled (here or anywhere else), failed or expired. If the relay does not confirm a Cancel, the page says so: treat the code as working until it shows Cancelled, and cancel again (that is safe).
  • Remove teammate and Remove computer change the team key ("Key changed to version N"). A removed teammate keeps history that was already downloaded, but receives nothing new.
  • Shared projects: Share future memories shares a project; Stop sharing (in the row's menu) stops sharing new memories, while memories already shared stay shared. Records are never forwarded from one team into another.

Progress and recovery

Sharing → In progress lists this computer's invitations, pairing codes and requests to join that are not finished, and "Requests from this computer that ended" keeps refused ones with their reason.

Shown Meaning What to do
Waiting for the inviting computer to finish connecting you the relay has your request; the inviting computer approves automatically while merkhain runs there nothing; it finishes on its own. Check now looks once
The relay has not confirmed your request the request may or may not have reached the relay, and nothing finishes it on its own Enter the code again (the same invitation or pairing code) before it expires; it resumes the same request
Not accepted yet: the relay did not accept the code / reports it expired / … the relay refused the request. Its answer is unsigned, so this computer keeps the request until it expires Enter the code again to send it again; if it keeps being refused, ask for a new invitation (or make a new pairing code)
Not accepted yet, after the relay has the request (e.g. the computer that made the code runs an older merkhain) the relay's status, or the approval it delivered, was refused; this computer keeps asking until the request expires nothing to send again; if it keeps being refused, ask for a new invitation (or make a new pairing code), for an older merkhain after updating that computer
Failed: the relay did not accept the code / the relay does not know this code the invitation can never work ask for a new invitation (or make a new pairing code)
Expired, Cancelled the code can no longer be used ask for, or create, a new one

An invitation the relay refuses never signs you out of My Memory; only an ended browser session shows the sign-in page, and an invitation link waiting in the page survives signing in again there. If the join answer is lost or the local service stops, the link stays in the page and Join team again sends the same request.

Keyboard and screen sizes

Both products work with the keyboard alone. The first Tab stop is Skip to content and the second is Sign out. After every navigation the focus moves to the page content. A memory opens as a dialog that keeps Tab inside it; Escape closes it and returns to the card it came from. On narrow screens, both products put navigation behind Menu in a drawer that keeps Tab inside it. Escape, Close menu, or the scrim closes it and returns focus to Menu; widening to desktop closes it and focuses the page content. Row menus (for example Stop sharing) also close with Escape. Status is always written out, never shown by colour alone. Both products fit a 360px-wide screen without scrolling sideways: My Memory has eight destinations (Home, Browse, Insights, Sessions, Prompts, Processing, Sharing, Settings), and Relay Administration tables scroll inside their own box.

Processing troubleshooting

Processing (formerly Queues) reads the same diagnoses as merkhain status. It refreshes every few seconds while visible and pauses in a background tab. From top to bottom: the overall status, the three stages (Read sessions, Create memories, Sync shared memories), Needs attention, Session progress, and collapsed Technical details (job inspector, leases, sequence ranges, raw codes).

A session's processing reads the same on the Activity session list, the session page and Processing:

Shown as Meaning
Needs attention a job failed after all its attempts; the session's later content waits behind it
Creating memories a worker is processing the session now
Waiting to process jobs are queued or retrying, or new content is not yet in a job
Captured content processed everything captured so far was processed (possibly into no memories)
Waiting for session content nothing has been captured from the session yet
Processing information unavailable the session was captured on another computer
On the page Meaning What to do
"Memories are not being created" extraction is not running (provider.kind: none, invalid provider config) configure a provider in config.json (Local memory); a running serve local picks it up
A session Needs attention, provider_error / timeout / rate_limited the provider failed and retries ran out fix the provider, Show jobs on the session, select the failed jobs, Retry selected failed jobs… and confirm; "Recent actions" follows each job from Retry queued through Processing to Completed or Failed
A session Needs attention, invalid_output the model output did not match the extraction contract use a stronger model, then retry
Waiting to process normal backoff after a transient error, or content waiting for enough activity wait; failed jobs can be retried at once
Read sessions: behind, transcripts that cannot be read a transcript was rewritten or could not be read Check for missed session updates (= merkhain repair reconcile)
Read sessions: "only N of M transcripts were checked" the status checks a bounded number of transcripts the rest are not reported; run merkhain status for the full picture
Sync shared memories: Not configured no team or Personal sync on this computer nothing; memories stay private
Sync shared memories: Retrying the relay cannot be reached check the relay URL/TLS, then Retry sync
"Shared memories cannot sync" (identity_locked) the key store is locked, so sharing is paused unlock it or set MERKHAIN_KEY_PASSPHRASE_FILE for the service
"Disconnected. … Showing data from …", Out of date the browser lost the service; the page shows the last data it received start serve local; after a restart sign in again
"Request failed … could not be confirmed" the action's outcome is unknown check the session's jobs before trying again

For the full list of diagnoses, see Troubleshooting.

Building the UI

Releases and go build embed the checked-in build in internal/webui/dist/, so building the binary needs only Go. To change the frontend you need Node 22.13 or newer (or 24+):

make web          # npm ci + vite build → internal/webui/dist (deterministic file names and content)
make api-types    # regenerate web/src/api/generated from the Go API types; needs only Go
make web-check    # API types, typecheck, unit tests, build; fails if they or the checked-in dist differ
cd web && npx playwright install chromium firefox webkit && npm run e2e   # browser suites against real binaries

Commit the regenerated internal/webui/dist together with the web/ change. CI fails when they differ.

Memory and activity evidence

Browse shows the active project/source scope and can reset all filters, including private search text. Search text stays in this page's memory, outside addresses, browser storage and copied links. Memory previews have Memory, Source and History sections; current means no newer record supersedes it, not independently verified. Feedback is offered only for a retrieval that reports its identity, and manual resolution remains limited to eligible local open to-dos.

Sessions uses the service's session_filters capability for all-project browsing and Claude Code, Codex, OpenCode or pi filtering. Filtering happens before paging on the service, including when more than 64 projects exist. Older services retain the bounded project listing and explicitly report that agent filtering is unavailable. Session details separate Knowledge, Timeline, Recalled context, Raw events and Details. A latest summary is shown only when a real summary record is returned. Raw events are available only on the capturing computer.

Prompt details separate Asked & reply, Context, Learned memories and Timeline. Prompt rows show how many memories the hooks delivered in that prompt's turn, and Context lists them with what triggered each and whether it went to a subagent. This links by the agent's turn identity (Claude prompt_id, Codex turn_id). Prompts from before this was recorded, agents that report no turn, and a subagent's own turns have no record, so an empty list is not evidence that nothing was delivered; the session's Memory context still lists those deliveries. It shows delivery only: merkhain cannot see what the agent used, and searches the agent ran itself (memory_recall, memory_search) are not linked to prompts. Return links retain filters and loaded pages in the current tab, along with scroll and focus. Reloading starts without private searches or retained pages.

Insights has Overview, Usefulness & reuse, Retrieval health, Method comparison and Usage history. The reporting window follows these links. Ratings are agent self-reports (memory_feedback) and stay separate from model judgments; the UI has no rating controls. Agents must rate every memory_recall and memory_search result as helpful or not helpful, unless Require agents to rate results (agent_feedback.disabled) is off. Only the agent can judge whether a result was useful, so ratings still measure what agents report, not an independent outcome. Coverage counts only those results, because hook-injected context carries no retrieval ID to rate. Delivery is not usefulness and missing rates are not zero. Operational method-overlap sample sizes per pair are not reported. Usage history continues to use its existing database-backed settings at /ui/insights/settings.

Processing distinguishes waiting work from failures and shows recorded retry times. The technical inspector retains actual jobs, worker/admission health and causes; retry controls still require confirmation and report the observed outcome. A failed refresh keeps the last good view marked stale, and hidden tabs pause polling.