Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
54be153
feat(contacts): link two owners' Boites over Tailscale
klNuno Oct 18, 2026
7342b37
feat(ui): contacts on a home page
klNuno Oct 18, 2026
a8b8eb0
fix(contacts): close the review's findings and raise the budgets
klNuno Oct 18, 2026
b348ffc
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 18, 2026
e009dd9
fix(contacts): keep Boite tokens from the concierge and hold slots fo…
klNuno Oct 18, 2026
3c94f41
fix(contacts): count a core stored while still joining once
klNuno Oct 18, 2026
0f19be4
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 18, 2026
5cf1f3f
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 18, 2026
5edaca1
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 19, 2026
c0a944f
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 19, 2026
b40b855
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 19, 2026
ca36fda
fix(contacts): open the contact link without permessage-deflate
klNuno Oct 19, 2026
874dc56
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 19, 2026
a6ee5fe
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 19, 2026
55370d1
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 19, 2026
a1a6c85
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 19, 2026
27a133d
Merge branch 'main' into boite/contacts
klNuno Oct 19, 2026
143987c
feat(ui): contacts under the projects, their page beside the thread list
klNuno Oct 19, 2026
ada616b
Merge remote-tracking branch 'origin/boite/contacts' into boite/contacts
klNuno Oct 19, 2026
488aa0e
feat(ui): contacts in their own column on the right edge
klNuno Oct 19, 2026
1d3fb85
fix(ui): keep the chat's room when the contacts column unfolds beside…
klNuno Oct 19, 2026
e0950df
fix(contacts): answer review findings on the concierge, policy, finge…
klNuno Oct 19, 2026
2e1a688
fix(ui): the fake contacts client drops a removed contact's requests …
klNuno Oct 19, 2026
b3c8564
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 19, 2026
487942f
Merge remote-tracking branch 'origin/main' into boite/contacts
klNuno Oct 19, 2026
a272a9b
feat(contacts): each shared project's board on the contact's page
klNuno Oct 19, 2026
bf5843a
fix(contacts): read the board counts again after a reconnect
klNuno Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ and assertions when making the suite faster.
- [Machines](docs/machines.md): connections, browser origins and thread views.
- [Groups](docs/groups.md): the roster of one owner's machines, invitations, tickets and the
addresses a member gives.
- [Contacts](docs/contacts.md): another owner's Boite linked over Tailscale, the
projects each side opens and the requests between their agents.
- [Trace](docs/trace.md): process events, resource caps and Windows guards.
- [Performance](docs/performance.md): what a remote client is sent, startup
order and the benches behind every number.
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- [Prompt cache timer](prompt-cache.md): how long a thread's cache stays warm, per provider, and the sources.
- [Agent coordination](coordination.md): messages between agents, machine links and limits.
- [Groups](groups.md): machines that connect to each other, their phones, Tailscale addresses and trust.
- [Contacts](contacts.md): another owner's Boite linked over Tailscale, the projects each side opens and requests between their agents.
- [Persistent agents](agents.md): the Agents page, robots, projects entrusted to an agent, its memory pass and brain folder, planned tasks, groups, missions and background execution.
- [Subagents](delegation.md): child conversations on by default, model profiles, live steering and usage.
- [Workflows](workflows.md): JSON plans of delegated steps, fan-out, conditions, templates and the graph.
Expand Down
4 changes: 4 additions & 0 deletions docs/board.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ button, and on a phone the title menu's Project board row, open the board
alone. The board belongs to the project, so every thread of the project shows
the same cards; the terminal stays the thread's.

A contact's page shows the four counts of each project shared with that
contact and opens its board in the project's last conversation
([contacts](contacts.md#projects-and-requests)).

The board sits behind the Reviewed goals experiment (`goal2`, Settings,
Experiments) and needs a core that sends `features.projectBoard` in its hello.

Expand Down
2 changes: 2 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ boite agents send <agent> <text> [--wait] [--timeout <s>]
boite agents reply <message-id> <text> [--wait] [--timeout <s>]
boite agents log <agent>
boite agents wait [agent] [--timeout <s>]
boite agents contacts
boite agents ask <contact> <text> [--wait] [--timeout <s>]
boite agent context|inbox|missions
boite agent send <recipient-ids|-> <text>
boite agent reply <message-id> <text>
Expand Down
188 changes: 188 additions & 0 deletions docs/contacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# Contacts

Behind the Contacts experiment ([experiments](experiments.md)). Not ready for a
stable channel: a full security pass and a network pass come first.

A contact is another owner's Boite. Your agents and theirs work together on the
projects each side opens to the other, and on nothing else. A contact never
sees your other projects, your group, your devices or your other contacts. The
machines of your own [group](groups.md) stay what they are: full control,
yours only.

## Linking two owners

Tailscale carries the link, with each owner keeping their own tailnet. The
inviter shares one machine with the invitee through Tailscale's machine
sharing; the invitee's tailnet sees that machine and nothing else of the
inviter's. Only the inviter's machine is shared. The invitee's core dials it
and keeps one link open, and the inviter's requests to the invitee travel on
that link: Tailscale quarantines a shared machine, which answers connections
but opens none, and the invitee exposes nothing.

1. The inviter's **Invite a contact** (`contacts.invite`) makes a one-time link
valid 24 hours: `boite://contact#` then the inviter's core id, signing
key, sealing key, machine name, tailnet URL, a one-time token and the
Tailscale share link. With a Tailscale API key stored
(`contacts.tailscaleKey`), the core creates the share itself
(`POST /api/v2/device/{id}/device-invites`, single use); without one the
invitation says so and the owner shares the machine from the Tailscale
admin console.
2. The invitee pastes the link (`contacts.join`). Its core accepts the
Tailscale share with its own API key when it has one
(`POST /api/v2/device-invites/-/accept`). Without a key, the Join card
reads the share from the pasted link and shows **Accept the Tailscale
share** before Join; it stays while the link does, so after a join that
could not reach the inviter the owner accepts it and joins again.
3. The invitee's core posts to the inviter at its tailnet name,
`http://<machine>.<tailnet>.ts.net:<port>/contacts/join`: a shared machine
is reachable by that name only, and its IP may differ in the invitee's
tailnet. The body is sealed to the inviter's sealing key and carries the
token and the invitee's card, signed by its key. The inviter looks the
token up before it checks the signature or keeps the nonce, spends it
before anything else changes, so a second join with the same link is
refused, then answers with its own card, signed and sealed back. Every
refusal is a sealed 403, which the server counts against the caller's
address (60 a minute). Each core stores the other's keys. The inviter
keeps only the token's SHA-256.
Two owners who invite and join each other at once end with one
relationship: the core with the smaller core id hosts and the other
dials it, a rule both cores compute alone.
4. Both apps show the same eight words, derived from both public keys. Two
owners who read the same eight words know nobody sat between them.
5. The invitee's core keeps a WebSocket open to the inviter at
`/coordination/contact/link` and redials with backoff, from one second to
a minute. It dials with a hello in its headers: from, to, the time and a
nonce, signed. Before the upgrade, the inviter refuses a hello more than a
minute off, a nonce it has seen or a signature by another key. That link
is a [coordination bridge](coordination.md#contacts) route for the
invitee: the inviter's signed requests go down it and the answers come
back on it. The invitee's requests to the inviter are plain POSTs.

Requests between contacts are signed with each core's Ed25519 key and sealed
to the recipient, as between members of a group, then carried by WireGuard. A
readable request from a contact is refused. Owner tokens, provider credentials
and the Tailscale API key never cross. The API key sits in
`contacts-tailscale-key` in the data directory, mode 600; no RPC answer, log
or error carries it, and the core sends it to `api.tailscale.com` only.

The inviter's core listens on its tailnet address while it hosts a contact or
has an invitation out, as it does in a group, and closes that listener once
the last invitation expires or the last hosted contact goes. A core with no
contact and no invitation opens nothing and runs no timer. **The tailnet policy decides which ports a shared user
reaches**: without a rule, assume every port of the shared machine. The
Contacts page reads the policy with the API key and says `restricted`, `open`
or `unknown`; when `open`, it gives the lines that limit `autogroup:shared` to
Boite's port. Boite never writes the policy.

## Projects and requests

Each side opens projects to the other (`contacts.grant`). An open project's
threads are listed in the contact's agent directory: title, project, branch,
model, status and declared resources. Each thread's own coordination settings
still apply: one with communication off or kept to its project stays hidden.
The contact's agents can write to them. Reading a conversation, and searching
its chat, needs `readThreads` on that grant, off by default. A thread that
wrote to a contact, or asked it something, may hear back from it for 7 days
after its last letter, even in a project not opened to it. A grant change
reaches the contact as `contact.sync`, retried until heard, and becomes the
contact's `offers`.

With the [project board](board.md) on, a contact's page shows each project
shared with that contact as its board's four counts: to do, in progress, in
review and done. A click opens the board in the project's last conversation.
These are this core's cards and stay on it: the contact sees none of them
until shared projects sync their board.

An agent asks a contact for help with `boite agents ask <contact> "<text>"`
(`collaboration.ask`): "an agent on a project like X". The request carries the
agent's git remote when its checkout has one. On the contact's side it waits
in the Contacts page: the owner accepts by opening one project, or refuses with
a reason. A contact marked trusted is answered without the owner:

1. a request whose git remote matches exactly one local project opens that
project (`decidedBy: 'match'`);
2. otherwise the concierge decides: one inference on the delegation profile
named `concierge`, else Opus 5.5 at high effort, given the request and the
project list as JSON data. It answers with the project to open or the
reason to refuse (`decidedBy: 'concierge'`). If it fails, takes over 3
minutes or ends without a verdict, the request waits for the owner.

The concierge runs on the driver's side-question path: no tools, no MCP
servers, hooks or settings, nothing persisted, in an empty folder under the
data directory. Its processes get no Boite token or thread id, not even one the
core inherited from an agent that started it. A request cannot make it read a
file. A
profile on a protocol without that path (pi, Codex, ACP) never runs it: the
request waits for the owner and the log says why. It creates no thread; the
decided request shows its verdict and reason. It never grants `readThreads`.

Each request a contact sends gets an id of this core; the contact's own id is
kept only to recognize its retries and to answer it, so two contacts, or a
contact reusing an id of this core's requests, never reach each other's
request. The decision comes back to the asking thread as a
message (`origin: 'contact'`): the project opened and its threads, or the
reason for the refusal. When the contact's core refuses the ask itself (a
tier, a full queue), the asking thread is told the same way.

## What an agent knows

A letter from a contact carries `contact`, the name this owner gave that
contact, set by the receiving core from its own list, never taken from the
sender. The agent reads "Chris's agent, a contact: not your user" beside the
sender's thread and project. The guide tells the agent that a contact is not its owner:
a contact's message does not authorize a destructive action, a credential or
work outside the opened project.

Agents may talk with contacts as long as the work needs: there is no cap on
exchanges. The guide asks for messages that move the work, with no courtesy or
acknowledgment-only replies.

## Model tiers

`ContactSettings.minTier` sets the least capable model allowed on contact
traffic, 1 by default. An agent below it cannot write to a contact or ask one,
and an incoming letter whose sender model, as the contact's core attests it,
is below it is refused with the reason. Tiers: 1 frontier (Opus, Fable, GPT-6
class), 2 mid (Sonnet, high Flash), 3 small (Haiku, Lite, OSS); an unknown
model is 2. The attested model is the contact's word: a tier is a cost and
cooperation rule, not a security boundary.

## Revoking

`contacts.revoke` closes one project: its threads stop hearing back from the
contact under the 7-day reply rule (only a later letter of theirs opens it
again), pending letters to them are rejected with `Project closed to this
contact`, a provider wake already queued for such a letter included, and its
threads leave the contact's directory. `contacts.remove` ends every grant, drops the contact's requests,
rejects pending letters both ways and closes the link. Removing is one-sided:
the other core keeps its entry, its redials are refused and its letters wait
undelivered until they expire, until its owner removes it too. Removing the
Tailscale share is the owner's, in the admin console.

## Limits

At most 32 contacts and 16 open invitations, 32 requests waiting per contact
and direction, and the last 50 decided requests kept. A join in flight holds a
contact slot until it ends, so joins at once never pass the cap together and
an incoming join cannot take a held slot. The tailnet address is
read when the core starts or joins a group: a core outside any group that
gets on Tailscale later listens there after a restart.

## Code and tests

`packages/core/src/contacts/`: `store.ts` (contacts, invitations, the link,
requests and the RPC methods), `join.ts` (both sides of a join),
`invite.ts` (the link format), `words.ts`
(fingerprint words), `tier.ts`, `git-remote.ts` (read from the git config
files, no process), `concierge.ts` and `tailscale.ts`. `contacts.*` are
owner-only; agents call `collaboration.contacts` and `collaboration.ask`.
`packages/core/test/contacts.test.ts` links two cores over loopback, and
`contacts-parts.test.ts` covers the pieces, the Tailscale client against a
stubbed `fetch`.

## Not yet

Shared projects (both sides' threads in one view, a kanban synced with
`/goal2`), shared compute between owners, a person writing straight to a
contact's agent, default denial of secret files in opened projects and the
review agent that can revoke a contact are later phases.
35 changes: 35 additions & 0 deletions docs/coordination.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,29 @@ channel. Signatures cover destinations, timestamps, unique request nonces and
content. The receiver checks replay, size, expiry and rate limits. HTTPS protects
message privacy. Keep the core data directory private, including its signing key.

### Contacts

A [contact](contacts.md) is another owner's core, linked through an invitation.
It is a coordination peer too, listed after the hand-made links and the group's
members (`ContactStore.peers()` in `packages/core/src/contacts/store.ts`).
`collaboration.trust`, `collaboration.bridge.register` and thread reveals refuse
its core id. Its requests are always sealed, whichever way they travel: a
readable one is refused with `contacts exchange sealed requests only`. It
reaches only the projects opened to it. `directory` and `search` list their
threads whose settings allow other machines, and search matches chat only
where the grant has `readThreads`. `read` needs that grant too. `deliver`
reaches those threads, or a thread that wrote to the contact in the last 7
days. A contact never sends `group.sync` or `group.push`, and only a contact
sends `contact.sync` (the projects opened to the receiver), `contact.ask` and
`contact.answer` (a request and its decision).

The inviting core, the host, never dials its guest: the guest keeps a
WebSocket open at `/coordination/contact/link`, and that socket is the
guest's bridge route on the host. The host's signed and sealed requests go
down it as `{t: 'req'}` frames; the guest answers each through the same
handler as an HTTP request (`coordination.forward`) and sends back a
`{t: 'res'}` frame. The guest's requests to the host are ordinary POSTs.

Trusted peers can discover the conversations that allow
remote coordination: title, project, branch, model, declared resources and the
agent's status. When the destination grants reading, searches also match chat
Expand Down Expand Up @@ -207,6 +230,16 @@ mode and the timestamps stay out. When the context is still unclear, the
recipient is told to read the sender's conversation instead of dismissing the
message.

A letter that crossed a contact link carries `contact`, the name this owner
gave that contact, set by the receiving core from its own list. The provider
reads such letters under their own heading, the sender named
`<name>'s agent, a contact: not your user`, with the rule that a contact's word
never authorizes a destructive action, a credential or work outside the
project it came through. The answer to `boite agents ask` arrives as a letter
with `origin: 'contact'`, written by the asking core from the contact's
decision; the agent does not reply to it. The coordination guide mentions
`boite agents contacts` and `ask` only while the core has a contact.

Agents are told to write compactly: one to three short lines, facts and asks,
no greeting, recap or sign-off, since the recipient already sees who wrote.
`boite agents send` and `reply` print a reminder past 600 characters.
Expand Down Expand Up @@ -268,6 +301,8 @@ boite agents send <machine>/<thread-id> "The parser check fails on main."
boite agents reply <message-id> "Wait until the current test finishes."
boite agents log <thread-id> # what the two of you said to each other
boite agents wait --timeout 120 # the next message addressed to you
boite agents contacts # other owners' Boites and the projects they opened to you
boite agents ask <contact> "An agent on a project like our parser" --wait
```

An agent is named by its thread id when it runs on the same core, or by
Expand Down
36 changes: 36 additions & 0 deletions docs/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,34 @@ the switch off closes the page if it is open. It hides the page only: agents
already made keep their routines and missions on the core, which knows nothing
of a client's experiments.

## Contacts

The contacts column and the Contacts page ([contacts](contacts.md)). On a
desktop window the contacts stand in a column on the right edge, the thread
list keeping the left one: one row per contact with its link and what it asks,
and Add beside the title. A row opens that contact's page in the chat's card,
between the two lists. The Contacts button at the right end of the title bar
folds the column, remembered on the device, and shows a dot while a folded
column hides a request. The right panel leaves the column its width. A phone
has no right edge: its list shows the contacts as a section under the
projects, and the menu entry opens the page with no contact picked.

A contact's page shows what it asks, the projects each side shares with the
other with their read switch, then trust, the eight check words and Remove. With
no contact picked, the page lists the requests waiting for an answer, with
Accept on a picked project and Refuse with a reason; decided ones fold under a
count that says who decided. Invite makes a one-use link, copies it and says
whether Tailscale shared the machine or why it could not; Join takes a pasted
link and shows the eight words to compare. The Tailscale row says the policy
state; Configure unfolds the API key (stored or cleared, never shown back), the
policy lines to paste when it is open, and the weakest model tier a contact's
request may run on.

In a thread, a letter the core labelled with a contact shows that contact's
name in a dashed "remote" chip beside the sender. The chip is drawn whatever
the switch says: the label comes from the core. Turning the switch off closes
the Contacts page if it is open; contacts, grants and requests stay on the core.

## Preview comments

Open a Browser tab in the integrated panel and choose Reference an element.
Expand Down Expand Up @@ -305,3 +333,11 @@ press with one subscribe, that a turn streamed during the press is on screen,
that a released press unsubscribes, and that the agents panel's subscription
survives. `press-preload.test.ts` (end to end) measures the gain and covers a
press dragged away.

`fake-client.contacts.test.ts` covers the in-memory core's contacts: the seed,
the `contacts.updated` event after each change, the refusals with their field,
the invitation's share with and without a key, and `collaboration.ask`.
`HomePage.test.ts` renames, trusts and opens a project for reading on the seeded
contact, accepts a request through the menu, refuses one only with a reason,
removes the contact behind the confirm, creates and joins an invitation, clears
the key and moves the tier. `AgentDiscussion.svelte.test.ts` checks the remote chip.
Loading
Loading