internkim

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 isWhere it lives
the recordPostgres behind row level securitysupabase/migrations
the control planeserver routes holding the service keyweb/src/routes/api/agent/
the appa SvelteKit build on Cloudflare Pages, serving the pages people sign intoweb/
the public APIserver routes in the same build, answering tokens and MCP clientsweb/src/routes/api/v1/
the connection gatewaya Worker with one Durable Object per company, carrying browser calls, API calls and the native messenger appsworkers/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:

answeredByWho runs itExamples
recordthe app itself, against the record, as the calling membertasks, the calendar, people, leave, attendance, CRM
companythe company's computer, reached through the gatewaymessages, mail, web search, reading a document
localonly the agent's own machine; the API refuses itthe 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.

ProcessAnswers onJobCode
the relay (internkim-relay)outbound only; 127.0.0.1:18091 for the connectoranswers the app's calls, turns messages into agent turns, serves the agent its toolshost/relay/
the connector (chatd)127.0.0.1:18090the messenger adapter for Buzz.dependency/blueclaw/chatd/
the agent (blueclaw)/run/internkim/acp/blueclaw-acp.sock; admin API on 127.0.0.1:8080the ACP agent: runs, approval, the task ledger, memory.dependency/blueclaw/
internkim-capabilityd/run/internkim/capability.sockholds provider credentials: model calls, the mail, web and browser tools; passes embedding requests on to the embedding servercmd/internkim-capabilityd/, internal/capabilityd/
Moli (moli serve), one per requester127.0.0.1:9230 and upthe device browser for plain public-text navigation; capabilityd starts each person's on first use, with their own profile, and stops it when idleinternal/browser/device_browsers.go
internkim-admind127.0.0.1:18080, /run/internkim/admind.sockthe workspace screens, the roster, and the company tools the plane carries to the hostcmd/internkim-admind/, internal/admind/
internkim-maild127.0.0.1:18092answers mail for whichever account a call carriescmd/internkim-maild/
internkim-embedding127.0.0.1:18095llama.cpp serving EmbeddingGemma 2 on the CPU; every memory and skill is embedded here, with weights the package carriesinternal/runtime/blueclaw/company_host_services.go
the agent's storeDATABASE_URLPostgres holding conversations, runs, policy—

What the agent is made of

blueclaw is one process built from separate repositories, each with one job:

JobWhere
blueclawthe agent host: identity, the approval gate, the task ledger, the ACP agent the relay talks to.dependency/blueclaw/
bluecollarthe agent loop and the model client, running in-process.dependency/blueclaw/.dependency/bluecollar/
blueprotocolthe contract packages blueclaw and bluecollar share: tool descriptors, task event names, approval targets.dependency/blueclaw/.dependency/blueprotocol/
bluememolong-term memory: facts extracted from finished work, scoped by owner and circle.dependency/blueclaw/.dependency/bluememo/
internkim-pluginthe 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.

  1. 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.
  2. The Durable Object forwards the call to the relay's standing connection, stamped with the calling member's ID.
  3. 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 the actor, so the messenger sees the member's own account doing the thing. A member who has connected no account gets a 409, and person.credential.issue is the call that connects one.
  • Mail (person.mail.*) goes to internkim-maild with 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 as mail_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 to admind over 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:

  1. chatd posts the event to the relay's /inbound on 127.0.0.1:18091. The relay writes it under RELAY_STATE_DIR and answers 202 only once it is on disk; chatd retries until it gets that answer, so a message survives either process restarting.
  2. 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.
  3. blueclaw runs the turn. Every tool call it makes to that address, the relay forwards to the app's /api/v1/mcp as the requester, with a member session it minted for them, so the agent never holds the bearer.
  4. 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 cloudanyone, from anywhere
Your own installation on your networkpeople on that network or its VPN
The host computer itselfpeople 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 nothing

Administering 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 %h

A 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.

On this page