Architecture
What talks to what, which process owns which step, and where each one lives in the tree.
Two machines. One holds what must survive any computer, the other runs the agent and holds what only makes sense on that machine.
Every arrow leaving the host points outward. None points in.
App read ordering and freshness
The web app and the native app's shared web screens use the same read paths. Authentication and company membership are verified before a route accepts account-scoped data. The signed-in member lookup is shared within the current account session; focus revalidation and account changes invalidate it.
Opening a task board starts task_board_get and person_list together. The
board reads visible status/date ranges and direct-child progress without the
company's complete history. Board snapshots are distinct from complete states
and keyed by account and week. List, report and member views request complete
history on demand. Opening and editing a card uses the board data; choosing
Task relationships requests the complete graph for progress, search and cycle
checks. Secondary panes load their JavaScript on first use. A saved complete task snapshot is
scoped to the project, company, member and role, expires after 24 hours, and is
revalidated. Writes, logout and denied access invalidate it; a response started
before invalidation cannot restore the old snapshot.
Week selection updates the URL before a pending board read finishes. Session
revalidation retains that selection, and a partial response for another week
cannot replace it. Week navigation remains available during refresh; record
editing still requires fresh authorized data.
The shell mounts before applying a stored board. Secondary task tabs mount on
first use and keep their drafts within the account scope. Gradient avatars use
observer-reported sizes and paint when visible, avoiding layout reads and
canvas work for every card during startup.
The application shell does not fetch the attendance workspace on every route.
The account menu, command palette and attendance workspace read the requester's
current state through attendance_current_get. Its caller-RLS attendance_current
RPC returns company time, work locations, today's own events, the latest own event
across all dates, and active approved leave in one snapshot. It accepts no target
identity and skips the whole-directory record context. Existing clock writes keep
their authoritative validation. The attendance landing page separately requests
attendance_team_page_get: caller-RLS team cards and searched employee pages
have fixed response bounds, and the employee filter runs before page enrichment.
Selecting one employee loads that person's month events and approved leave by
exact member ID. The company-wide month comparison remains an explicit action.
Concurrent own reads share a promise; a verified memory snapshot lasts 15 seconds
within the same company day. Focus, visibility, explicit refresh and successful
record/leave changes refresh the own state independently of team history. Stored
monthly display summaries never supply authoritative clock controls. Disposed or
globally invalidated work-status reads cannot repopulate persisted rows.
Record tools validate their descriptor, input and caller permissions before
using the caller's RLS-enabled database client. Company settings and people are
independent reads and run concurrently when both are needed; settings-only and
CRM list reads omit the unused people directory. Context is not cached across
authenticated requests. company_settings_get accepts includeProfileImage: false for callers needing settings without signing an image URL; existing
callers still receive the image. CRM activities filter linked organizations
before pagination. CRM records and activity counts use stable pages, preserving
records beyond the database response cap. Task and event write readbacks select
the saved identifier through the caller's client. Update and delete resolution
first reads a named UUID with the same caller and event-kind scope. Missing IDs
and title hints retain the existing title resolver and relationship checks.
Calendar events render before the holiday overlay finishes. Each range load shares its timezone read; late holidays merge with current local edits, while navigation and disposal discard superseded ranges. National-holiday failure does not hide company events. Live attendance summaries carry their input records into overlapping work-status reads. Persisted summaries carry no such records and must revalidate. Leave details load when displayed. These changes use their existing history contracts. The own-current and team-page reads add two narrow RPCs; there is no generic batch contract. The selected-person month still calls the existing leave reader, whose security-definer query reads company history before JavaScript filters it.
The shell loads search and legacy identity UI on demand. Central sign-in does not wait for the company computer's messaging identity; Messenger claims it when needed, bound to the verified project, account, company and session. Transport changes reject pending old replies. Existing message and asset caches discard late results from a previous account.
Mail reads its account, then fetches mailbox and message lists once in parallel. A message link seeds its mailbox before loading and selects only the requested list-derived UID, paging when needed. Missing messages keep a visible error and retry through refresh. Leave approvals start policy, pending requests and member labels together; the existing leave RPC still reads company history before filtering. Notification preferences load alongside push status; status reads use the current worker registration without waiting for worker activation. Preference switches require confirmed reachability. Settings keeps its general section mounted while resolving the role. Data-room and operations screens publish independent valid responses as they arrive. CRM waits for complete core records and currency metadata, while owner-directory enrichment loads separately. Only directory-dependent inputs wait for that enrichment; mutations refresh their affected record lists.
The service worker caches shipped static files as controlled pages request them. Installation does not download every route's chunks or unrelated static images. Cached files can be reused offline; authenticated HTML and API answers are not cached. The asset cache preserves unchanged hashed URLs across updates and removes entries absent from the current build. Mutable static files use the network with a cached offline fallback. Pending notification destinations and other caches remain separate.
Measure actual fixture cards and fresh response markers, rather than empty columns or response headers. Browser-restart tests close and relaunch the browser with the same profile; snapshot display and fresh data completion are separate measurements. These checks do not establish physical native-device performance or SQL execution time.
The repository runners web/scripts/measure-navigation.ts and
web/scripts/measure-task-restart.ts compare production Workers against the same
disposable local record. They reject remote endpoints, create owned fixtures
and clean them up. Alternate revision order, exclude the warmup pair, freeze
both builds, and run one browser workload at a time. Record build fingerprints,
runtime, browser, viewport, CPU rate, backend identity and host load. Report
sample count, median and observed range; keep protocol versions separate.
Twelve samples do not establish a population p95. A 390px touch viewport with
CPU throttling is a simulation, not a physical phone.
Open the mobile More menu before timing a language change; menu preparation is
outside that scenario's timer.
Task revisit and forward-navigation readiness can use a visible saved snapshot; they do not prove that background revalidation finished. Refresh tests change an owned fixture title before starting the timer and await the new DOM marker. The normal realtime broadcast remains enabled, so refresh traffic may contain both scheduled and manual reads. Report the 500ms preparation used for intent prefetch separately from click completion. API body durations include HTTP, authentication and application work; SQL statement counters require a separate read-only observation on the same isolated database.
Integration and browser runners select API and SQL URLs from the same local
stack status. When SUPABASE_URL is set, SQL tests require an explicit
SUPABASE_DB_URL; both loopback ports must match this checkout's
supabase/config.toml before any SQL connection opens. Run DB workloads
serially because a browser fixture reset invalidates an integration run.
One plugin across harnesses
internkim-plugin is the shared client package. InternKim's own agent loads the
skills from it, and Claude Code, Codex and Pi can install the same package to
use the company's tools from their own loop.
The plugin is a plain Agent Plugins checkout: plugin.json, mcp.json naming
https://api.intern.kim/v1/mcp, and skills/. There is no harness adapter, and
each client loads that layout its own way; the plugin's README has the recipes.
The service authors each tool contract once, derives the actor from the caller's authentication and checks current company membership, so a model's arguments cannot select a more privileged identity. MCP carries tool calls. ACP is the separate boundary by which the relay hands messenger conversations to the agent, and using the plugin alone needs no ACP session. Installing the plugin does not expose blueclaw's workspace or terminal to an external agent.
Inside blueclaw the model's kernel is read, write, edit, bash, plan and
equip. plan records the goal, its steps and the level that sizes the run.
equip takes a sentence describing a need and answers with the tools that serve
it, and the rest of the company catalog reaches the model as a shortlist settled
once per plan step. The model speaks through a single reply that carries the
words and any files, and that can park the run until somebody answers or offer
choices as buttons.
The central side
Five things, deployed by us.
| What it is | Where it lives | |
|---|---|---|
| the record | Postgres behind row level security | supabase/migrations |
| the control plane | server routes holding the service key | web/src/routes/api/agent/ |
| the app | a SvelteKit build on Cloudflare Pages, serving the pages people sign into | web/ |
| the public API | server routes in the same build, answering tokens and MCP clients | web/src/routes/api/v1/ |
| the connection gateway | a Worker with one Durable Object per company, carrying browser calls, API calls and the native messenger apps | workers/connection-gateway/ |
The record answers every read and write as some member, never as an administrator. Which rows a session sees is decided by the database itself. The tables are listed in What lives where.
The control plane exists for what a member cannot do alone. Its callers
authenticate with a company's agent key, and what it hands back is a session:
POST /api/agent/session turns an identity the company knows into a member
session, POST /api/agent/host-session signs the company's computer in as
itself. Sibling routes under the same directory let that signed-in host read
the credentials it needs per call (/api/agent/messenger-credential,
/api/agent/mail-account). The service key never leaves these routes.
Notifications are sent by the project's own edge functions
(supabase/functions/), so the key that reads every member's devices stays
inside the company's Supabase.
The app decides at runtime which Supabase project it talks to:
hooks.server.ts injects the values into the #central-plane element, so one
build serves every company. The pages hold no secret; the API routes under
/api/ do, which is why they run on the server. Everyone signs in at one
address; every other hostname on the zone answers 308 to it, except paths
under /api/ and /v1/, which are answered where they land because a
cross-origin redirect would drop the caller's bearer token. That exception is
what lets api.<zone>/v1 and a company host's own /api/v1 be the same API.
The public API is where the tool catalog lives. Every tool is defined once,
in web/src/lib/server/public-api/catalog/, and each definition names who
answers it:
answeredBy | Who runs it | Examples |
|---|---|---|
record | the app itself, against the record, as the calling member | tasks, the calendar, people, leave, attendance, CRM |
company | the company's computer, reached through the gateway | messages, mail, web search, reading a document |
local | only the agent's own machine; the API refuses it | the browser, model calls, embeddings |
/api/v1/mcp serves the same catalog to any MCP client, and it is also how the
agent itself reaches its tools, as the sections below show. The
catalog lists every tool.
The connection gateway is how a browser and the host reach each other when
neither accepts a connection. The browser opens a WebSocket to
/company/<id>/client carrying its Supabase session token; the Worker
verifies the token against the project's JWKS, resolves which member is
calling, and hands the socket to that company's Durable Object
(CompanyConnectionObject). The host's relay dials /company/<id>/server
from its side and stays connected, authenticated by a server key the Durable
Object stores. The object forwards calls one way and answers the other, pushes
message.arrived to open chat screens when the relay reports a new message,
and tells browsers whether the company's computer has a live connection.
A native messenger app on a phone or a desktop dials the company's messenger
address, <slug>.<zone>, which is a route on the same Worker and not a Pages
hostname. The Worker finds the company by the first label and opens a stream
on the host's standing connection: stream.open, then stream.frame and
stream.close, each carrying a stream ID. For each stream the relay dials the
company's Buzz relay on loopback and presents the address the app dialled, so
the app's NIP-42 login reaches the relay that checks it, and the gateway never
reads a frame. A frame over 512 KiB, the Buzz relay's own limit, is refused
before it enters a stream. When the host's connection drops, every stream
closes with 1012 and the apps dial again. The relay sends streams out in
turn, one frame each, so one busy subscription cannot hold the connection.
The rest of what an app sends is HTTP. NIP-98 queries, invitations and the relay's information document travel as calls on the same connection. Media never enters a frame. For a blob the app asks for, the host first asks its relay whether that app may have it, copies it once, a range at a time, into the transfer store the web's file copies use, and the Worker streams it from there with the range the app asked for. An upload is staged in the bucket and the host hands it to its relay from there. A redirect would be simpler, but the desktop app refuses one when it saves or copies a file.
The Durable Object accepts every socket through the WebSocket Hibernation API. Each socket's role sits in its attachment, the runtime answers the host's keep-alive ping without waking the object, and a member's call still waiting for its answer is kept in the object's storage, so the object can sleep between messages and lose nothing.
The processes on the host
The package registers each as its own service with systemd or launchd; Running the host shows how to check them.
| Process | Answers on | Job | Code |
|---|---|---|---|
the relay (internkim-relay) | outbound only; 127.0.0.1:18091 for the connector | answers the app's calls, turns messages into agent turns, serves the agent its tools | host/relay/ |
the connector (chatd) | 127.0.0.1:18090 | the messenger adapter for Buzz | .dependency/blueclaw/chatd/ |
the agent (blueclaw) | /run/internkim/acp/blueclaw-acp.sock; admin API on 127.0.0.1:8080 | the ACP agent: runs, approval, the task ledger, memory | .dependency/blueclaw/ |
internkim-capabilityd | /run/internkim/capability.sock | holds provider credentials: model calls, the mail, web and browser tools; passes embedding requests on to the embedding server | cmd/internkim-capabilityd/, internal/capabilityd/ |
Moli (moli serve), one per requester | 127.0.0.1:9230 and up | the device browser for plain public-text navigation; capabilityd starts each person's on first use, with their own profile, and stops it when idle | internal/browser/device_browsers.go |
internkim-admind | 127.0.0.1:18080, /run/internkim/admind.sock | the workspace screens, the roster, and the company tools the plane carries to the host | cmd/internkim-admind/, internal/admind/ |
internkim-maild | 127.0.0.1:18092 | answers mail for whichever account a call carries | cmd/internkim-maild/ |
internkim-embedding | 127.0.0.1:18095 | llama.cpp serving EmbeddingGemma 2 on the CPU; every memory and skill is embedded here, with weights the package carries | internal/runtime/blueclaw/company_host_services.go |
| the agent's store | DATABASE_URL | Postgres holding conversations, runs, policy | — |
What the agent is made of
blueclaw is one process built from separate repositories, each with one job:
| Job | Where | |
|---|---|---|
| blueclaw | the agent host: identity, the approval gate, the task ledger, the ACP agent the relay talks to | .dependency/blueclaw/ |
| bluecollar | the agent loop and the model client, running in-process | .dependency/blueclaw/.dependency/bluecollar/ |
| blueprotocol | the contract packages blueclaw and bluecollar share: tool descriptors, task event names, approval targets | .dependency/blueclaw/.dependency/blueprotocol/ |
| bluememo | long-term memory: facts extracted from finished work, scoped by owner and circle | .dependency/blueclaw/.dependency/bluememo/ |
| internkim-plugin | the skills the agent loads for a kind of work | .dependency/internkim-plugin/ |
Memory is one SQLite file per subject, in that subject's protected directory in the workspace, as What lives where lays out. When a run finishes, bluememo has a model extract self-contained sentences from it; a newer sentence replaces an older one, and a person can ask the agent to forget one. Recall searches the file by embedding and by words and answers without calling a model. The embedding is made on the host, by the embedding server, so no text a person remembered leaves the computer to be embedded. None of it lives in Postgres.
Models are named in one place, internal/modelladder. The agent asks for a
tier, and capabilityd holds the key and makes the call, so the agent never sees
a provider token.
How the chat screen gets answered
The app reads and writes the record directly as whoever signed in, so attendance, leave, the calendar, work and the org chart never touch the host. The screens that show what only the host knows go through the gateway: the messenger, mail, the agent's runs and the approvals they wait on, its memory, its files, and the skills it loaded.
- The browser sends
{ kind: "call", capability, body }down its gateway socket. The token was verified when the socket opened; nothing in the body is trusted to say who is asking. - The Durable Object forwards the call to the relay's standing connection, stamped with the calling member's ID.
- The relay serves it and answers on the same socket, and the object routes the answer back to the browser that asked.
What "serves it" means depends on the capability, and each branch is in
host/relay/forward.ts:
- Messenger operations are forwarded to the connector at
/v1/platform/<platform>/<capability>. First the relay fetches that member's own messenger credential from the control plane and attaches it as theactor, so the messenger sees the member's own account doing the thing. A member who has connected no account gets a409, andperson.credential.issueis the call that connects one. - Mail (
person.mail.*) goes tointernkim-maildwith the member's mail account fetched per call, so no mail password is at rest on the box. The web app seals each password to the company key with HPKE (RFC 9180) when it is saved, bound to the company, the member, the field and the server it signs in to, and maild opens it with the copy of that key every box of the company keeps in/var/lib/internkim/box. Changing that server, port, security or login asks for the password again. A company with no box key keeps the readable form, and every such save is logged asmail_account.kept_unsealed. - Workspace views (
person.memory.*,person.files.*,person.runs.*,person.skills.*,person.persona.*and their siblings) and the public API's company tools (person.api.request,person.api.file) are forwarded toadmindover its unix socket. Moving a file is described below. - Link previews (
asset.link) are built by the relay, which stores the preview image in Supabase Storage so the answer stays small.
No account in the running system stands for somebody else. Every member's work
on the messenger is done with a credential that member registered, and nothing
keeps an administrator session to act on anyone's behalf: the relay, the
connector and the company's connection record hold no messenger account of
their own. A member registers theirs from the app, through the same gateway
socket that names them on every other call. What a person hands over is the
messenger's to say. person.credential.requirement answers whether it wants a
sign-in, a secret or a redirect and which fields to draw, and
person.credential.issue turns the answers into a durable credential, proving
it works before anything is stored.
An answer larger than the byte ceiling is refused with a 413 naming the
size, because the transport would otherwise drop it silently; the ceiling and
its default are in Running the host.
How a file crosses
A file never travels on the socket. The call only names it, and the bytes go
through the company's asset bucket, whose prefixes are listed in
What lives where.
To read one, the browser calls person.media.prepare for a messenger
attachment or person.files.download for a workspace file, with a
transferID it chose. The relay checks the request as the member: the
messenger reads with the member's own key, and admind lists the directory as
the member before anything is read. The relay then copies the file into the
bucket with a resumable upload, asking the connector or admind for one 6 MiB
range at a time. A copy that already exists is answered at once with its
address. Otherwise the call answers pending, and the relay delivers
transfer.progress events while it copies, then transfer.done with the
address or transfer.failed with the reason. The browser signs the address
with its own session, so row level security decides who may open it.
To send one, the browser uploads it into its member's upload/ prefix with a
resumable upload of its own, then calls person.media.upload or
person.files.upload naming the object. The relay reads it with the member's
session. For a message, the connector fetches it from a short-lived signed
address, spools it to disk while hashing it and puts it in the messenger's
media store, and the message is then sent naming what came back. For the Files
screen, the relay streams it to admind, which has Blueclaw write it as the
member's POSIX identity. Either way the upload is deleted once it is taken.
The agent sends a file the same way. Its reply names the file by its workspace
path, and the relay reads it through admind as the person the reply answers,
so only what that person may read is posted. The relay copies it into that
member's transfer/ prefix, calls person.media.upload with the member's key,
and posts a message as the agent naming what came back. The reply counts as
delivered only when both the upload and the post landed. A reply's files go out
before its words, so when a file does not arrive the person gets a notice
naming it instead of words saying it is attached.
No process holds a whole file. Bun reads an HTTP response as fast as it arrives, whatever the reader does with it, so every read is one bounded range.
How a message becomes work
The connector attaches to the messenger as the company's bot member, through
the adapter matching MESSENGER_PLATFORM
(.dependency/blueclaw/chatd/src/adapters/), and normalizes each arriving
message. From there the relay carries it:
- chatd posts the event to the relay's
/inboundon127.0.0.1:18091. The relay writes it underRELAY_STATE_DIRand answers202only once it is on disk; chatd retries until it gets that answer, so a message survives either process restarting. - The relay opens one ACP session for each person who speaks in a
conversation, on
/run/internkim/acp/blueclaw-acp.sock, and sends the message as a prompt in the sender's own session. The session names who is asking and where to reply, and it names the relay's own/mcp/<ticket>as the agent's tool server, so a second person in a channel never runs as the first. - blueclaw runs the turn. Every tool call it makes to that address, the relay
forwards to the app's
/api/v1/mcpas the requester, with a member session it minted for them, so the agent never holds the bearer. - Each reply comes back over ACP as the agent writes it, naming the thread it belongs in. The relay posts it there through the connector and tells blueclaw which message it became, or why it was not posted. blueclaw records a reply as sent only on that word.
Approval travels the same way. When the gate holds a call, blueclaw asks the relay over ACP, the relay posts the question into the thread it names and reports the post as it does a reply, and the requester's next message in that thread is the answer. blueclaw decides what those words mean, and anything it cannot read as a yes declines. The relay keeps the open question on disk beside the inbound queue, so a restart on either side asks nothing twice and loses nothing.
The agent behaves identically whichever client the person typed in.
Separately, the messenger server reports every message it accepts to the
relay on the same loopback port. The relay resolves who was addressed and asks
the notify edge function to reach them, which arrives as a web push wherever
those members registered a device. It also tells the gateway, so open chat
screens fetch the new message.
How someone in the messenger becomes someone in the record
A person who has never opened the app still has a row in the record. Whatever acts for them on the host proves the company with its key, names the identity that spoke, and receives a session for that member.
POST /api/agent/session
Authorization: Bearer <the company's agent key>
{ "kind": "email", "externalID": "…" }The relay asks when it opens a tool server for a requester, and again five minutes before that session expires. The control plane refuses an identity nobody claims, and refuses a member whose company is not the one that key belongs to. What comes back is an ordinary member session, so everything the agent does in the record is bounded by that person's own permissions.
Where the messenger server runs
The diagram puts it outside the host, which is one of three places it can be.
| Who can reach it directly | |
|---|---|
| A vendor's cloud | anyone, from anywhere |
| Your own installation on your network | people on that network or its VPN |
| The host computer itself | people on that network, and nobody else |
The last row is ordinary. A company that runs its own Buzz relay
often runs it on the machine already kept awake for the agent, and the
connector reaches it over loopback; the package runs the Buzz stack exactly
there, as buzz-relay and buzz-media, and a native app reaches it through the
gateway. Because the chat screen always goes through
the relay, one screen works wherever the messenger lives, and the
conversation is never written centrally: the relay sends back only the answer
to the call it was asked.
Why the host has no inbound port
Exposing a company's computer means a tunnel, a certificate, a hostname, and a new way in. Instead the browser, the messenger apps and the host all connect outward to the gateway, which passes calls and streams between them. Your firewall stays as it was.
Once it belongs to a company, the host proves this about itself with one command:
ss -ltnp | grep -v '127.0.0.1\|::1' # prints nothingAdministering the machine is a separate question with a separate answer. On
the same network, ssh reaches it and nothing else is needed. From
elsewhere, put whatever you already use in your own ~/.ssh/config:
Host my-company-host
ProxyCommand cloudflared access ssh --hostname %hA Cloudflare Tunnel is one convenient answer, and the one we use for our own company's host. Tailscale, a jump host and WireGuard are others. internkim installs none of them, asks for none of them, and cannot tell which you chose: the choice is yours and it stays outside the product.