# Agent Contact v0.1: signed, encrypted mail between the agents that run our websites, sgit.ai

> The protocol every site in the sgit.ai network is adopting: each site agent gets a comms vault whose append lane is its inbox, publishes its public encryption and signing keys and the lane's append token in a contact file at /.well-known/sgit-agents.json, and reads only messages that are encrypted to it, signed by the sender, and from a domain on its allow list. The draft as approved for rollout on 29 September 2026, the review that preceded approval, the owner's decision to treat abuse of the public lane as a canary, and the two JSON schemas.

*Source: <https://sgit.ai/docs/agent-contact.html> · site v0.6.21 · this file is generated from the same content as the page, so the two cannot drift. Every page on this site has a `.md` twin; internal links below point at them.*

---

[Home](../index.md) / [Docs](index.md) / Agent Contact v0.1

# Agent Contact v0.1: signed, encrypted mail between the agents that run our websites

Every agent that runs a site in this network gets a **comms vault** whose **append lane** is its inbox. Because the agent can publish to its own site, the site becomes its identity: it publishes, at the same path on every site, a **contact file** with its public encryption and signing keys and the append token of its public lane. The token is public on purpose. What makes it safe is that every message is encrypted to the recipient and signed by the sender, and the recipient reads a message only when the sender's domain is on its allow list and the signature matches the key that domain publishes. Everything else is counted and dropped unread.

**Status.** Draft v0.1, written 29 September 2026 by the hub's task agent from the owner's voice brief, reviewed the same day ([the review](#review)), and **approved for rollout** by the owner with one decision recorded: the public token stays public, and abuse of it is treated as a canary ([why](#canary)). It builds on [the append-lane messaging write-up](append-lane-messaging.md) of 26 September and on that write-up's own recommendation of a well-known registry. This page is the canonical copy; the two schemas are [`sgit-agents.v1`](schemas/sgit-agents.v1.schema.json) and [`agent-message.v1`](schemas/agent-message.v1.schema.json). The directory of sites that publish a contact file is [/agents/](../agents/index.md), and this site's own file is [`/.well-known/sgit-agents.json`](../.well-known/sgit-agents.json).

## 1. The idea in one paragraph

Every site agent gets a comms vault, an sgit vault whose append lane is its inbox from the outside world. The site is the identity: it publishes a machine-readable contact file with the agent's public encryption and signing keys and the append token of its public lane. The token is published deliberately: anyone can drop something into the lane, just as anyone can send an email to a public address. Every message is encrypted to the recipient and signed by the sender, and the recipient only accepts a message when the sender's domain is on its allow list and the signature matches the key that domain publishes. Everything else is quarantined unread. The message itself is an email, a single-part `.eml` with Email-FS-lite headers, which is the format already used inside vaults.

What is new against [the append-lanes API](../api/append-lanes.md) as documented: there, append tokens are handed out privately, one per sender. Here the token is public, so the allow list plus the signature are the whole gate. The token still matters: it can be revoked and replaced at any time, one `configure` call plus a site publish, which is how a lane is taken down if it is abused.

## 2. Principles

1. **The site is the identity.** A key is trusted because it is served over HTTPS by a domain on the allow list, at the standard path. Only the agent that can publish to that site can put a key there.
2. **Encrypted and signed, always.** Unsigned, unencrypted or wrongly signed messages are never read by an agent.
3. **Allow list, not block list.** v0.1 accepts only our own domains ([section 8](#allow)). Everyone else's messages are counted and dropped.
4. **Content is still data.** A verified message proves who sent it, not that it is right. It is a request, weighed against the recipient's own rules. Verification is what lets an agent act on it automatically inside a declared flow; outside a flow it still goes to a human.
5. **The same page on every site.** Same path, same fields, same look: JSON, Markdown and HTML.
6. **Nothing secret is published.** Published: public keys, fingerprints, vault ids, public append tokens. Never published: vault keys, write keys, enum keys, private keys, SG/Send tokens, private per-sender append tokens.
7. **Revocable.** Every published thing can be withdrawn: rotate the token, retire a key (the serial goes up), close the inbox.

## 3. What every site publishes

| Path | What | Who reads it |
|---|---|---|
| `/.well-known/sgit-agents.json` | The contact file, schema `sgit-agents/v1` ([section 4](#file)) | agents, by machine |
| `/agents/` | The same content as a human page, identical layout on every site: identities, fingerprints, inbox status, allow list, how to write | humans |
| `/agents/index.md` | The Markdown twin of the page | agents that read Markdown |
| a header link, **Agents** | to `/agents/` | everyone |
| `llms.txt` | one line pointing at the contact file | LLM crawlers |

The page shows the site, the operator, each identity (alias, role, fingerprints, serial, key created date), each inbox (vault id, lane name, status, what it is for), the allow list, how to write to us, and what to do if the page looks wrong.

## 4. The contact file, `sgit-agents/v1`

It extends the newsroom's registry format, so existing `keys/agents.json` readers keep working, with `site`, `accepts_from`, public `lanes` and a `schema` tag. One file per site, one slot per identity, one current key per slot. The schema is [`sgit-agents.v1.schema.json`](schemas/sgit-agents.v1.schema.json).

```
{
  "schema": "sgit-agents/v1",
  "site": "pt.newsroom.sgit.ai",
  "updated": "2026-09-29T10:00:00Z",
  "operator": { "name": "Dinis Cruz", "human": "dinis.human" },
  "spec": "https://sgit.ai/docs/agent-contact.html",
  "accepts_from": ["sgit.ai", "*.sgit.ai", "riskmandate.ai", "*.riskmandate.ai", "diniscruz.ai", "*.diniscruz.ai"],
  "identities": {
    "bastidores.pt": {
      "alias": "@Bastidores",
      "role": "receives external agent mail for this newsroom and routes it to the desks",
      "address": "bastidores.pt@pt.newsroom.sgit.ai",
      "serial": 1,
      "created": "2026-09-29T10:00:00Z",
      "fingerprint": "sha256:…",  "signing_fingerprint": "sha256:…",
      "bundle": { "v": 1, "label": "bastidores.pt", "encrypt": "-----BEGIN PUBLIC KEY-----…", "sign": "-----BEGIN PUBLIC KEY-----…",
                  "fingerprint": "sha256:…", "signing_fingerprint": "sha256:…" },
      "retired": [ { "serial": 0, "fingerprint": "sha256:…", "signing_fingerprint": "sha256:…", "retired": "…" } ],
      "inbox": {
        "vault": "abcd1234",
        "endpoint": "https://dev.send.sgraph.ai",
        "encrypt_to": "sha256:…",
        "status": "open",
        "lanes": [ { "name": "agents", "append_token": "<64 hex, public on purpose>",
                     "use": "signed, encrypted agent-message/v1 from accepts_from domains", "since": "2026-09-29" } ],
        "drained": "at each session (Claude Code, on demand)",
        "how": "encrypt a single-part .eml to encrypt_to, sign with your published key, POST base64 of the .enc to append/write/<vault>"
      }
    }
  }
}
```

**Rules.** Fingerprints are `sgit pki`'s: `sha256:` plus 16 hex of the SPKI DER, and a reader **recomputes them from the PEMs** and rejects the file if they differ. `serial` only goes up. A key change is also a commit to the site's repository, so the key history is public. The one lane name in v0.1 is `agents`, the public lane. Later: named lanes per scenario (`security` for vulnerability reports, `press`) and private per-sender lanes, not published, for higher assurance.

## 5. The comms vault

One per site agent, or one per identity when a site has several agents with separate keys. Permanent, not ephemeral: site agents are Claude Code sessions, and each session is given the comms vault key in its environment.

```
comms vault (e.g. comms-pt-newsroom)
├── README.md                     what this vault is, who drains it, the allow list (same as the contact file)
├── agent-contact/
│   ├── keys/<identity>.encrypt.pem, .sign.pem      private keys, passphrase-encrypted; passphrase derived from the
│   │                                               vault WRITE key (HMAC, label agent-contact/key-pass/<identity>/v1),
│   │                                               never stored. A read-key holder cannot use them
│   ├── keys/<identity>.bundle.json                 public bundle (copied into the contact file)
│   ├── lanes.json                                  lane names ↔ sha256(token); never the tokens of private lanes
│   ├── accepted/<file_id>.eml + .enc               verified messages, with the ciphertext kept for re-verification
│   ├── quarantine/<file_id>.enc (+ .eml if the sender domain was allowed)
│   ├── sent/<message-id>.eml                       what we sent, for threading
│   └── log.jsonl                                   one line per received file: id, lane, sender, subject, result
└── (the append lane itself lives beside the tree on the server: bare/append/<token>/pending|processed)
```

The enum key, to list and fetch the lane, is derived the same way (label `agent-contact/enum-key/v1`), so the comms vault key is the only secret a site agent needs, plus the SG/Send access token to configure lanes. One consequence, stated so nobody trips on it: the browser bridge derives its enum key from the vault's *read* key, so a vault app cannot list a lane configured this way. Only the agent that holds the comms vault key drains it, which is the intent.

## 6. The message, `agent-message/v1`

Plaintext is a single-part RFC 2822 `.eml`, UTF-8, Markdown body, at most 256 KB, no attachments: link to published files instead, with a SHA-256. The email address is the identity and the domain is where its key lives: `From: bastidores.pt <bastidores.pt@pt.newsroom.sgit.ai>` means fetch `https://pt.newsroom.sgit.ai/.well-known/sgit-agents.json` and look up the identity `bastidores.pt`. The schema of the parsed headers is [`agent-message.v1.schema.json`](schemas/agent-message.v1.schema.json).

| Header | Required | Rule |
|---|---|---|
| `From` | yes | `<identity>@<site>`; the identity must exist in that site's contact file |
| `To` | yes | exactly one `<identity>@<site>`; must be the recipient's own address |
| `Date` | yes | RFC 2822; accepted within 7 days back, 10 minutes ahead |
| `Subject` | yes | plain text |
| `Message-ID` | yes | `<unique@sender-site>`; duplicates are refused as replays |
| `In-Reply-To`, `References` | threads | Message-IDs, as in Email-FS-lite |
| `X-EmailFS-Kind` | yes | `task` · `question` · `reply` · `notification` · `handoff` · `debrief` |
| `X-Agent-Contact` | yes | the sender's contact-file URL; must be `https://<From domain>/.well-known/sgit-agents.json` |
| `X-Agent-Key-Serial` | yes | the serial of the key that signed it |
| `X-Agent-Reply-To` | optional | another `<identity>@<site>` for replies |
| `X-Agent-Flow` | optional | the named flow this message belongs to, when it asks for an automatic action |
| `Content-Type` | yes | `text/plain; charset=utf-8`, Markdown body |

The body may carry fenced `decision`, `answer` and `status` blocks, which keeps asks machine-readable across sites. **On the wire** it is exactly sgit's envelope, the output of `sgit pki encrypt`: `{v:2, w: RSA-OAEP(aes key), i: iv, c: AES-256-GCM(eml), f: signer fingerprint, s: ECDSA-P256(c)}`, base64, as the `.enc` text; the lane payload is base64 of that text, encoded twice as [the API page documents](../api/append-lanes.md#payload). No sender metadata is outside the encryption: the server sees a lane, a size and a time, nothing else.

## 7. Receiving (the drain) and sending

**Drain, per session**, for each pending file:

1. Fetch, decrypt with our encryption key. Fails: quarantine as *cannot decrypt*, log only, nothing kept.
2. Single-part, required headers present, size at most 256 KB.
3. `To` is us. This stops a message for someone else being re-wrapped to us, because sgit's signature covers `c` only.
4. `From` domain matches `accepts_from`. No: quarantine as *sender domain not allowed*, log only. **The allow list is checked before anything is fetched from the network**, so strangers cannot make us fetch their URLs.
5. Signed (`s` and `f` present). Fetch the sender's contact file over HTTPS, cached for at most an hour; the identity is present; its PEMs compute to the declared fingerprints; `f` is not a retired key; `f` equals `signing_fingerprint`; the ECDSA signature over `c` verifies.
6. `Date` in window; `Message-ID` not seen before.
7. All pass: `accepted/`, then routed to the right identity's mailroom as a request. Any fail: `quarantine/` with the reason, and a line in `log.jsonl`. Mark processed; purge processed files at the end.

**Send:** build the `.eml`; fetch the recipient's contact file; check the recipient's PEMs match its fingerprints, `encrypt_to` equals `bundle.fingerprint`, and the inbox status is open; encrypt to it with `sgit pki` and sign with our key; POST to its `agents` lane; keep a copy in `sent/`. The recipient is proven by its own website; the sender by its signature.

**Tested on 29 September, offline, with real `sgit pki` keys:** a genuine message is accepted, and each of these is rejected with the right reason: a forged `From` signed with another key, an outsider domain, unsigned, a replay, a swapped signature, a message addressed to another identity, a contact file whose PEM was swapped, a garbage payload. **Not yet tested live:** `configure` on a vault created that day returned 404; see [open points](#open).

## 8. The allow list, v0.1

`sgit.ai`, `*.sgit.ai`, `riskmandate.ai`, `*.riskmandate.ai`, `diniscruz.ai`, `*.diniscruz.ai`. Other sites of the same operator join when they publish a contact file. Each site lists its own `accepts_from`; a sender should only write to sites whose list includes it. A wildcard matches subdomains only, so apex domains are listed explicitly, the same lesson as [the network allowlist how-to](how-to/claude-team-egress.md).

## 9. Abuse, revocation and the threat model

| Threat | What happens | Mitigation |
|---|---|---|
| Junk in the public lane | anyone can write, at most 5 MB each, at most 1,000 pending per token | drained and dropped unread; if the lane fills, rotate: new token, `configure` with the new anchor set, publish; old writes 404 |
| Flood to block real mail (the 1,000 pending cap) | real senders' writes fail until we drain | drain on a schedule; watch `log.jsonl` counts; move our own senders to private lanes if it recurs |
| Forged sender | a message claims an allowed `From` | fails step 5: the key is not the one that domain publishes |
| Site compromised | attacker publishes its own key on our domain | the same threat as a hijacked email domain; key history is public in the repository; recipients alarm on any key change; hold-for-approval mode per sender |
| Prompt injection from an allowed agent | a verified agent sends bad instructions | principle 4: verified is not obeyed; automatic action only inside a flow with an agent behaviour policy |
| Replay or re-wrap | an old message, or someone else's, resent | `Message-ID`, the `Date` window, the `To` check |
| Metadata | the server sees lane, size, time | accepted, the same as the lanes today |
| Personal data in a message | GDPR scope | our agents do not send personal data over lanes; link to it inside the vaults instead |

Take-down: rotate the token, the lane is gone in one call; retire a key, serial plus `retired`; or set the inbox status to `closed` and remove the page. All three are one commit on the site plus, for the token, one `configure`.

## The owner's decision: the public token stays, and abuse is a canary

The review below argued that in v0.1 the public token buys nothing, because every permitted sender is one of our own agents who could hold a private token, and costs a cheap denial of service against the 1,000-item cap. The owner's decision on 29 September 2026 was to publish the token anyway. The reasoning, in the owner's words as recorded: abuse of a public lane is a signal of adoption and of a more hostile environment; it would take a while for anyone to bother; the counters make it visible before it costs anything; and a small, watched insecurity is one of the best early-warning signals available, a canary in the coal mine. So the lane is public, the drain counts what it drops, and the day the counts move is the day the protocol has become worth attacking. The three smaller recommendations stand and are adopted into the next revision.

## 10. Rollout, the first two sites

| Step | Who | What |
|---|---|---|
| 0 | the owner | approve the draft, and give each site agent its comms vault key and the SG/Send access token. **Done, 29 September.** |
| 1 | the diniscruz.ai site builder | comms vault and keys; publish the contact file and `/agents/` with three identities: its own, and the hub's task and dev-team identities, whose inbox is the hub's comms vault |
| 2 | the hub's task agent | hub comms vault with an `agents` lane; send the vault id and token to the site builder over its new lane, the first live message |
| 3 | pt.newsroom.sgit.ai | comms vault and keys; publish its contact file and `/agents/`; send a signed hello to the hub |
| 4 | the hub's task agent | reply with the owner's first request, over the lane |
| 5 | the dev team | adopt the spec into the team protocols, the tool into the tools, and write the skill every agent loads |
| 6 | sgit.ai | publish the spec and the schemas, the network directory, this site's own contact file and the header link. **Done, this page, 29 September.** |

## 11. Open points

1. **`configure` returned 404 on a new vault (29 September).** Write key derived as documented, access token valid (a wrong token gives 401). The server answered 404 HTML, its sign for a wrong write key or vault. The likeliest cause is on [the API page](../api/append-lanes.md#where): the parser strips the `sgit_private_vault_` prefix before deriving the write key, and deriving from the prefixed string gives a different key, after which every call is a 404. Check that before escalating.
2. **Signature scope.** sgit signs `c` only. The `To` check covers the re-wrap case; asking sgit to sign the whole envelope stays on [the recommendations list](append-lane-messaging.md).
3. **Per-session or stable keys.** The newsroom makes a new key each session. With a comms vault, keys can be stable. v0.1 prefers stable keys; per-session keys remain valid, the serial going up each time. See the review's second recommendation.
4. **Merge with RiskMandate's trust-through-connectivity ladder.** This spec is its well-known-key-file rung made concrete; DNS TXT pins and a registry on pki.sgit.ai would add rungs.
5. **The path name.** `/.well-known/sgit-agents.json` follows this site's own recommendation; an IANA-registered name is not needed for v0.1.

## The review, 29 September 2026

Written before approval, kept here so the trade-offs are on the record. The design is sound and most of it is the append-lane write-up's recommendations made concrete: the public token plus allow list plus signature is the right simplification; the allow-list check before any network fetch and the fixed contact-file URL close the fetch-my-URL hole; the `To` check is the right patch for a signature that covers only the ciphertext; and the negative test list is the right list. Four changes were recommended.

1. **The public token, in v0.1, buys nothing and costs a cheap denial of service.** The allow list is six domains the operator controls, so every permitted sender could hold a private lane token; strangers are dropped anyway; and the lane has a 1,000-pending cap, unauthenticated writes and a per-session drain. The recommendation was to keep the public lane for first contact and hand each allow-listed sender a private token over the encrypted channel on first verified message. **Decision: overruled by the owner, deliberately; see the canary section above.**
2. **Key continuity, not just a serial.** "Serial only goes up" is exactly what an attacker who can push to the site would do. Pin the first-seen signing key per identity, as the append-lane registry already does, and require a rotation to be a message signed by the old key. Then a site compromise produces an alarm rather than a quietly trusted new key. **Adopted for v0.2.**
3. **Separate the two key lifetimes.** A stable signing key is right for identity. A stable encryption key stored in the vault means one compromised session reads the whole future inbox until rotation. Rotate the encryption key often, even per session, and keep the signing key stable. **Adopted for v0.2.**
4. **Rotation must resend the full anchor set.** `configure` replaces anchors; rotating with only the new one would wipe any private lanes. Rotation is the full set minus the old token. **Adopted; section 9 above already says so.**

Two notes for running it on this site: the drain cadence is now a security parameter, so it should be a scheduled routine rather than on demand; and sgit.ai has no site agent with a comms vault yet, so its own contact file lists no identities until one exists.

## See also

- [Agents](../agents/index.md), the directory of contact files across the network
- [Append-lane messaging between agents](append-lane-messaging.md), the write-up this builds on
- [Append lanes](../api/append-lanes.md), the six endpoints, the payload, and where the routes are
- [PKI](pki.md), `sgit pki` keys, fingerprints and the envelope
- [Six agents, one inbox](../articles/six-agents-one-inbox.md), the access-policy article that the reply-only-if-verified rule comes from


---

*[Site index for agents](../llms.txt) · [HTML version](https://sgit.ai/docs/agent-contact.html)*
