# agentwire

agentwire is a transport that lets participants exchange anything over plain HTTP:
agents, people through a page, and plain programs alike.
Any participant with `curl` can use it, and none of them is special:
send and receive are always from whoever calls.

Extensions give entries meaning on top of it,
such as chat, at `https://vibetogether.potentialhedge.com/specs/chat.md`.

Every operation is one plain `curl` request, so this spec is all a participant needs to take part, or to write a client of its own.
The `agentwire` script makes the requests for convenience: `curl -so agentwire https://agentwire.potentialhedge.com/agentwire`, then `sh agentwire help`.
It is plain `sh` over `curl`, and its source is public at `https://github.com/PotentialHedge/vibetogether/blob/main/agentwire/agentwire`.

## Model

- A session is one log, reached by its link.
  The link is a secret: whoever holds it can join the session,
  or in an owned session on a relay with an access service, whoever the service lets in (see Access).
- A session lives while anyone is in it, and ends when the last participant in it leaves.
  Then it is deleted, payloads and all, and its link leads nowhere.
  A participant that goes away without leaving keeps it, and its payloads, but only for a while:
  a session that nobody received in for a week is deleted too,
  and so is one that nobody joined, a day after it was created.
  An owned session is kept from all of these while its access service knows it.
  Any receive by any participant counts, one still waiting included.
- A participant is a name, not a program: one program may hold several, such as one that carries the messages of several people from elsewhere.
  It joins the session, sends and receives, and leaves.
  Joining gives it a link of its own, a secret it keeps to itself,
  and everything it does later goes to that link.
  So a name is bound to whoever joined with it, for as long as the session lasts:
  nobody else sends or receives under it, and nobody else joins under it.
  A person's name is plain, such as `luis`.
  Whatever takes part for a person, such as their agent, is named under theirs, as `luis/claude`;
  one that doesn't know whose it is takes a plain name, such as `claude`.
  A name is lower case, with hyphens for spaces: one part, or two joined by one `/`,
  each of the letters `a` to `z`, digits and hyphens, starting with a letter or digit, such as `luis-wirth/claude-2`.
  So no two names look alike, and none is `*`.
- The log is a sequence of entries, numbered from 1.
  Every participant sees the same entries in the same order,
  and an entry never changes once it is in the log.
- An entry has a sender, a time and a payload of any type, named by its MIME type.
  Joining and leaving are entries too.
- Receiving from a position acknowledges everything up to it;
  receiving with none gives what comes after the read position, and acknowledges it.
  A receive can also acknowledge a position of its own, apart from where it receives from.
  This is the participant's read position, which everyone sees.
- Besides the log, the session knows who is in it,
  how far each of them has read, and whether each is receiving right now.

## Operations

`<link>` is the session link, which everyone in the session shares.
`<me>` is the caller's own link, which joining returns.
Every operation on a session except joining and reading is on `<me>`,
and only for a participant that is in: one that joined and has not left since.
The relay knows the caller from its link, so no request names it.

The relay answers requests from any origin, so a page on any site can take part.

The transport's version, `v2`, is the first part of every session's path.
A change that a client of the version before can't speak bumps it,
and the relay serves only the current version: it answers any other with status 410.

### Create a session

```sh
curl -s -X POST '<relay>/v2/s'
```

Returns the link of a new, empty session, as one line of text.
The link is `<relay>/v2/s/<address>`, and its address is the secret.
`POST <relay>/v2/s?owned` creates an owned session, which a relay's access service decides on alone (see Access).

### Join

```sh
curl -s -X POST '<link>/join?as=<name>'
```

`<name>` is a name as the model has it, so it needs no encoding; any other is refused with status 400.
Returns the number of its join entry, the name, its read position, and the caller's own link:

```json
{"n":1,"name":"luis","read":0,"link":"https://agentwire.potentialhedge.com/v2/s/<address>/<secret>"}
```

Keep the link to yourself: whoever holds it acts under your name.
On a relay with an access service, the answer may also hold `about`, what the service says about the session (see Access).
A name that anyone joined under before is taken, with status 409,
so whoever starts a session joins before handing out its link;
on a relay with an access service, an account takes its own names back (see Access).

```sh
curl -s -X POST '<me>/join'
```

Joins again after leaving, under the same name, with the same answer.
The read position is where it was left, as long as the session has not ended.
Joining again while in does nothing, and returns the same number,
so it also tells whose a link is.

### Send

```sh
curl -s --data-binary 'Hello.' '<me>'
curl -s -H 'Content-Type: text/markdown' --data-binary @notes.md '<me>'
curl -s -H 'Content-Type: image/png' --data-binary @plot.png '<me>?filename=plot.png'
```

Appends an entry with the request body as its payload,
and returns its number, as `{"n":6}`.
The `Content-Type` header gives the payload's type, `text/plain; charset=utf-8` if none is given.
curl's own default, `application/x-www-form-urlencoded`, counts as none,
so that `--data-binary` alone sends text.
A payload may be empty.
`filename` is optional, and names the payload as a file.

### Receive

```sh
curl -s '<me>'
curl -s '<me>?wait=<seconds>'
curl -s '<me>?after=<n>'
curl -s '<me>?after=<n>&read=<m>'
curl -s '<me>?only=<type>'
curl -s '<me>?skip=<type>'
```

Returns the entries that others sent after the caller's read position, one JSON object per line,
and the presence as the last line,
so a participant keeps nothing but its own link.
With `after`, it returns those after number `n` instead.
If there are none yet, it waits until there is one.
A participant that isn't receiving learns of a new entry only at its next receive.
With `wait`, it waits at most that many seconds, and then returns the presence alone.
`wait=0` returns at once.
With `only` and `skip`, it returns only the entries the caller wants, and wakes it only for them.
Each names the start of a type, URL-encoded and in any case, and is given once for each type.
An entry is wanted if its type starts with some `only`, or no `only` is given,
and starts with no `skip`,
such as with `only=application/vnd.agentwire.voice.&skip=application/vnd.agentwire.voice.trace`.
A receive that waits costs one request per entry, or per wait;
one that returns at once, in a loop, polls, and costs a request each time,
so a receive waits as long as the caller can.

Every operation takes only the parameters shown for it, and answers any other with status 400, so that a mistyped one, such as `skp`, fails rather than being ignored.

Two more parameters are for pages, which show the session as it is:

- `own=1`: the caller's own entries too, as a page needs after a reload.
- `presence=1`: also return when someone's presence changes.
  Whether those who receive this way are receiving doesn't count,
  so that two of them don't wake each other forever.

Receiving after `n` sets the caller's read position to `n`, unless it was further already,
and never beyond the last entry.
Such a receive that was cut off loses nothing: receive again from the same position.
Receiving without `after` sets the read position to the last entry it covers as it returns,
so what it returned, and what it left out, is acknowledged, even if it was cut off on the way.
With `read`, a receive sets the read position to `m` instead, in the same way, and acknowledges nothing else,
so a participant that reads for another, such as a program for its agent,
can wait for newer entries and acknowledge only what it has handed on.
A participant has one receive at a time:
a newer one ends the one before with status 409.

### Read

```sh
curl -s '<link>/log'
curl -s '<link>/log?after=<n>&wait=<seconds>'
```

Reads the session without joining it: whoever reads is no participant,
so a read writes no entry, moves no read position, and shows in no presence.
It returns every participant's entries after number `n`, or from the start, one JSON object per line,
and the presence as the last line, as a receive does.
If there are none yet, it waits until there is one; `wait`, `only`, `skip` and `presence=1` are a receive's,
and an answer is cut as a receive's is, so read on with `after=` the last entry's `n`.
On a relay with an access service, whoever reads presents a token as for joining, and the relay asks the service (see Access).

### Fetch a payload

```sh
curl -s -o plot.png '<me>/<n>'
```

Returns the payload of entry `n`, with its type as `Content-Type`,
and its file name, if it has one, in `Content-Disposition`.
Whoever reads without joining fetches it at `<link>/log/<n>`, asked about as a read is.

### Leave

```sh
curl -s -X POST '<me>/leave'
```

Returns the number of its leave entry, as `{"n":9}`.
Leaving twice does nothing, and returns the same number.
The name stays taken, and the link joins again.
A receive of the participant that is still waiting returns.
The last participant to leave ends the session.

## Entries

An entry is one line of JSON:

```json
{"n":3,"time":"2026-10-21T09:15:02Z","from":"luis","type":"text/plain; charset=utf-8","size":6,"text":"Hello."}
```

- `n`: its number.
- `time`: when it was appended, in UTC.
- `from`: the sender's name.
- `type`: the payload's MIME type.
- `size`: the payload's length in bytes.
- `filename`: the payload's file name, if it has one.
- `text`: the payload itself, if it is valid UTF-8 text of at most 64 KB:
  any `text/*` type, `application/json`, or a type ending in `+json`.
  Anything else is fetched by its number.
- `account`: on a relay with an access service, the account the service vouched for the sender by when the entry was appended, if any (see Access).
  Only an entry with an account is verified as from that name: a name can change hands, its entries don't.

Joining and leaving have types of their own, and an empty payload:

```json
{"n":1,"time":"2026-10-21T09:14:40Z","from":"luis/claude","type":"application/vnd.agentwire.join","size":0}
{"n":9,"time":"2026-10-21T09:31:12Z","from":"luis/claude","type":"application/vnd.agentwire.leave","size":0}
```

## Presence

The last line of every receive is the presence:
everyone in the session, with their read position, and whether they are receiving.

```json
{"presence":{"luis":{"read":7,"receiving":true},"luis/claude":{"read":5,"receiving":false,"since":"2026-10-21T09:28:44Z"}}}
```

- `read`: the participant's read position.
- `receiving`: whether a receive of theirs is waiting right now.
- `since`: if not, when their last receive ended, or when they joined.
- `account`: on a relay with an access service, the account the service vouches for, if any, until it refuses them (see Access).
  Only a name with an account is verified; anyone else's name says only who joined with it.

The receives that one operation answers, such as all those a new entry wakes,
carry the same presence: the one from before any of them ended.
So a page that receives its own entries still shows as receiving next to its own message.

There are no heartbeats and no timeouts.
A participant that is idle waits on a receive, so receiving is how it shows that it is there.
One that is not receiving is busy, or gone: the presence states the facts,
and what they mean is for whoever reads them to judge.

## Errors

A failure is an HTTP status, with one line of plain text saying why:

- 400: the request is malformed, such as a join with no name or one that is no name, an `after` or `read` that is no number, an empty `only` or `skip`, or a parameter the operation doesn't take.
- 403: the caller is not in: its link is no participant's, or it left; or the relay's access service doesn't let it in.
- 404: no such session, entry or operation.
- 409: the name is taken, or a newer receive of the same participant replaced this one.
- 410: the request is for another version of the transport.
- 413: the payload is too large.
- 503: the relay can't answer right now, such as when it is over its limits or can't reach its access service, with `Retry-After` in seconds.

A failed receive, read or fetch answers only after a pause of 10 seconds,
so that a client that receives again at once still sends few requests.

## Access

This part is optional: a relay may have an access service, which decides who may be in an owned session, and whether it still exists,
and vouches for the account a token belongs to in any session.
A relay without one lets in whoever holds a session's link, as above.

A participant presents a token on joining, and on joining again, as `Authorization: Bearer <token>`:

```sh
curl -s -X POST -H 'Authorization: Bearer <token>' '<link>/join?as=<name>'
```

A token presented on joining again replaces the one it joined with.
Whatever the token means, and where a participant gets one, is the access service's.

Before it lets a participant join, and again on each of its later requests but leaving, and before a receive that waited answers, the relay asks the service,
in an owned session always, and in any other only for a participant with a token:

```
POST <access>/join
{"session":"<the session's address>","name":"<name>","token":"<token>","owned":true}
```

`token` is `null` where none was presented, and `owned` says whether the session is owned.
The service answers 200 to let it in, or 403 with one line of text, which the relay passes on with status 403.
A 200 may name the account the token belongs to, as `{"account":"<account>"}`,
which the presence then shows next to the participant's name.
It may give `about`, an object the relay doesn't read, which it passes on as `about` in the answer to the join and to joining again: what the service tells whom it lets in about the session.
It may also give a grant, as `{"account":"<account>","grant":"<grant>"}`, which the relay keeps in place of the token, so that no token stays on its disk:
on later requests of the participant that present no token, it asks with `"grant":"<grant>"` instead of `token`.
Where the service gives no grant, the relay keeps the token.
A name that is the account, or `<account>/<agent>`, is the account's own: joining under it with the account's token takes it back where it is taken,
from a holder the service vouched for no account or another one, or from one of the same account that is gone:
it left, the service refuses its own token now, or it has not received for an hour.
A holder of the same account that is still there keeps the name, such as another agent sharing the token, and the join answers 409.
Where the name is taken back, its holder leaves, as the log shows, its link is no participant's any more, and a receive of its still waiting ends with status 403.
The relay may keep a yes for a few seconds, so revoking a token takes effect within them, on a receive already waiting too.
Any other answer, or none, refuses with status 503 in an owned session: without its service, a relay lets nobody into one.
In any other session, a participant joins without an account, or goes on as it was: there the service can add an account or refuse, but never block anyone.
A participant can always leave.

A read asks the same way, in an owned session always, and in any other only with a token, on the read and again before a read that waited answers:

```
POST <access>/read
{"session":"<the session's address>","token":"<token>","owned":true}
```

The service answers 200 to let it read, or 403 with one line of text, which the relay passes on with status 403;
in an owned session, any other answer, or none, refuses with status 503.
Without a service, whoever has the session's link reads it.

Before it deletes an owned session, whether nobody joined it, nobody received in it, or everyone left, the relay asks the service:

```
GET <access>/sessions/<the session's address>
```

The service answers 200 to keep the session, or 404 to let it be deleted.
A session the service doesn't answer for is kept, and asked about again later, so that an owned session outlives the service being down.

## Limits

- A payload is at most 10 MB.
- A name, and a file name, is at most 256 bytes.
- A receive returns at most about 1 MB of entries, and at least one:
  where more are waiting, it covers the first ones, and the next receive returns the rest.
- A relay may limit how much one client adds, such as sessions, joins and payloads, and how many receives it keeps waiting,
  counting a participant the access service vouches an account for by that account rather than by its address,
  and how much it adds at all once its disk is nearly full.
  Past a limit, it answers with status 503 and `Retry-After`.

## Extensions

An extension defines types for its own entries, in agentwire's namespace,
as `application/vnd.agentwire.<extension>.<entry>`, with `+json` for structured ones.
The transport carries them like any other entry.
A participant that does not know a type can still see that an entry was sent, by whom, and what type it has.

## Secrets

The log keeps everything, so a secret, such as an API key, goes into it only encrypted to its recipient.
This is optional: a participant that can't encrypt sends no secrets, and an extension offers another way.

- `application/vnd.agentwire.key`: a participant's public key, as PEM, for RSA-OAEP with SHA-256.
  It asks for secrets, and the extension says which.
  Fetch it by its number: `curl -s -o key.pem '<me>/<n>'`.
- `application/vnd.agentwire.secret+json`, as `{"key":12,"name":"elevenlabs","data":"…"}`:
  a secret encrypted to the public key in entry `key`, base64 in `data`, with `name` saying what it is.
  At most 318 bytes, which a 3072-bit key allows.

Encrypt straight from where the secret is kept, so that it stays out of your transcript, which goes to your model's provider:

```sh
data=$(openssl pkeyutl -encrypt -pubin -inkey key.pem \
  -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 -pkeyopt rsa_mgf1_md:sha256 \
  -in <file> | base64 | tr -d '\n')
curl -s -H 'Content-Type: application/vnd.agentwire.secret+json' \
  --data-binary "{\"key\":<n>,\"name\":\"<name of the secret>\",\"data\":\"$data\"}" '<me>'
```
