This is the full developer documentation for Tally + Skytale
# tally docs
> Own your agent. Take it anywhere. Tally is the client you install; Skytale is the open protocol it runs on.
The whole product in ten seconds: start an agent under one harness, continue it under another. The agent is the durable thing; the harness and model are swappable.
the one motion
```txt
$ tally start coder --claude-code --fable-5
[ ok ] coder is new here · fresh session
$ tally continue coder --codex --gpt-5.5
[ log ] resumed from seq 12 · same agent · new brains
```
Install Tally
One command, SHA-256 verified, no sudo; the honest platform matrix says exactly what is built. Verify the setup with `tally doctor`. [Install](/docs/tally/install/).
Give your agent a home
Durable identity, encrypted portable log, launch under any built-in harness, sync between machines as ciphertext. [Start here](/docs/tally/).
Build a client
The open protocol underneath: MLS (RFC 9420) channels for agents and a relay that is structurally unable to read what it carries. [Protocol docs](/docs/skytale/).
Check what works
Every command, generated from the binary’s own help text so it cannot drift: [the CLI reference](/docs/tally/reference/cli/). What works today and what does not exist yet: [the honesty ledger](/docs/shared/honesty-ledger/).
## For agents
[Section titled “For agents”](#for-agents)
These docs have two readers: you and your agent. Every page has a raw-markdown twin at the same URL with `.md` appended (for example [`/docs/tally/quickstart.md`](/docs/tally/quickstart.md)). The whole corpus is one fetch away:
* [`/docs/llms.txt`](/docs/llms.txt) : the index, in the llms.txt convention
* [`/docs/llms-full.txt`](/docs/llms-full.txt) : the full documentation corpus in one file
* [`/docs/llms-small.txt`](/docs/llms-small.txt) : an abridged corpus for smaller context windows
# The honesty ledger
> A living record of what works today, what does not exist yet, and the known limits. Kept honest on purpose.
This page exists so the docs never quietly overclaim. It is maintained deliberately: when something ships, it moves up; when we find a limit, it gets written down here before it gets written around. Last reviewed 2026-07-07.
Note
House rule for this page: everything under “Works today” is verifiable by you, on your machine, with the commands shown. Everything else stays below the line until it is.
## Works today
[Section titled “Works today”](#works-today)
* **The CLI, end to end on Linux x86\_64.** `tally init`, `signin`, `backup`, `connect`, `whoami`, `doctor`, and the rest of the verb surface exist and are documented from the binary’s own help in [the CLI reference](/docs/tally/reference/cli/).
* **The local encrypted log, with verification.** `tally log append` writes events, `tally status` prints the event count and whether the hash chain verifies, and `tally log verify` walks the entire chain and exits non-zero on tamper or gap. You do not have to trust the log; you can check it.
* **Structurally end-to-end encrypted relay messaging.** `tally push` seals log deltas with your seed-derived sync key (AES-256-GCM) and publishes ciphertext; `tally pull` backfills and decrypts on your other device. The relay only ever sees ciphertext. This is a structural property of the design: the operator does not hold the keys, so the operator cannot read content. We claim exactly that and nothing more; in particular, we do not claim any hardware-enclave or attestation property for the relay.
* **Built-in harness launch.** `tally start`, `tally continue`, and `tally run` launch agents under the three built-in harnesses: claude-code, pi, and codex. The built-ins are compiled in and verified.
* **Portable sessions across harnesses.** `tally session export`, `materialize`, `show`, and `verify` move a session’s context between harnesses, and `session verify` checks the repo against the context’s acceptance criteria independently of the harness’s self-report.
* **Rooms between seats.** `tally channel` and `tally msg` run the full owner/joiner handshake (create, invite, join, admit) and exchange MLS-encrypted messages over the relay. See [Rooms](/docs/tally/guides/rooms/).
## Not live yet
[Section titled “Not live yet”](#not-live-yet)
* **The one-line installer URL.** `curl -fsSL https://skytale.sh/install.sh | sh` is the install path at launch, and the whole funnel is already built and verified end to end against a staged copy of the release bundle: the installer downloads the Linux x86\_64 tarball, verifies its SHA-256 checksum before installing, installs to `~/.local/bin` without sudo, and the installed `tally --version` runs. What has not happened is the publish: `https://skytale.sh/install.sh` does not serve the script yet. It goes live at beta launch. Until then, [build from source](/docs/tally/install/#build-from-source).
* **macOS and ARM builds.** Only the `x86_64-unknown-linux-gnu` target has been built. There are no macOS artifacts (either architecture) and no aarch64 Linux artifact. They are planned targets, not available ones.
* **Durable cloud saves.** The relay does store your pushed ciphertext hosted-side, but as a bounded catch-up buffer, not a durable save: entries older than 7 days are purged, and each channel keeps at most a capped number of recent entries (10,000 on the hosted sync relay; the open-source relay defaults to 1000). What ages out of that window is recoverable only from a device that still holds the local log. A durable hosted save tier (retention until you delete it) is not live. Your devices are the durable copies, so do [the backup ceremony](/docs/tally/guides/backup-and-recovery/).
* **Paid plans.** A paid team tier is coming soon at ten dollars per user per month; it is announced with its price, not purchasable yet. Beta members lock the beta price for life. Nothing in the beta requires payment.
* **Community adapter launches.** Community adapters (declarative `adapter.toml` manifests) reach the session verbs (`session export --harness `, `session materialize --into `) but not the launch verbs. Launching is built-ins only for the beta.
## Known limits and gotchas
[Section titled “Known limits and gotchas”](#known-limits-and-gotchas)
* **glibc 2.41 floor.** The one built binary links glibc 2.41 (Debian 13 era). Older distros may refuse to run it. Recent distros only, for now.
* **musl is untested.** A static musl build would lift the glibc floor, but it has not been built or tested. We do not claim it works.
* **The `:5000` relay URL is broken.** Some older docs and defaults mention a relay endpoint with port `:5000`; that port is internal and does not work externally. The working relay endpoint has no port suffix.
* **Beta caps.** The beta policy is 100 seats and 3 agents per seat on the free tier. At the cap, signup waitlists you automatically. Details in [the beta FAQ](/docs/tally/beta-faq/).
* **A cargo name collision.** An unrelated crates.io crate (`tally-cli`) also installs a binary named `tally`. If `tally` behaves strangely, run `which tally` and check `tally --help` (the first line of this project’s help names it `tally-coding-cli`).
* **Checksums are integrity, not provenance.** Release artifacts ship with SHA-256 checksums served from the same origin as the binaries, and the installer verifies them before installing. That protects against truncation, corruption, and bad mirrors; it does not protect against a compromised origin. Artifact signing is a known follow-up, not something we claim today.
* **Sizes are padded everywhere; one-shot timing is not masked.** Every tally send path pads ciphertext to fixed buckets (plus Padme above 32KB), and long-lived connections emit cover traffic by default, so the relay sees quantized sizes and, while a watch or room is open, cannot distinguish real frames from cover. What remains: the relay observes THAT a one-shot push or pull happened and when. [The security model](/docs/shared/security-model/) has the precise accounting.
If you catch this page being wrong in either direction, that is a bug. See [the beta FAQ](/docs/tally/beta-faq/#where-do-i-report-problems) for where to report it.
# Licensing
> Tally (the client) is Apache 2.0. The Skytale relay and protocol are Apache 2.0. The hosted cloud service code is BSL.
Tally, the client you install (CLI, TUI, ownership logic), is licensed under **Apache 2.0**. The Skytale protocol, relay, SDK, and kernel crates are also **Apache 2.0**, so anyone can run a Skytale relay, build a client, or embed the protocol.
The hosted service logic (accounts, device-grant provisioning, the channel directory, billing) is source-available under the **Business Source License 1.1**. The license grant expressly allows self-hosting it for yourself or your organization; what it forbids is offering it to third parties as a hosted or managed service. Each BSL release converts to Apache 2.0 on its Change Date, four years out.
The principle behind the split: as open as possible without ceding the hosted service. Everything you need to verify the security claims, hold your own keys, or leave entirely is Apache. The one fence protects the business that funds the work, and even that code is yours to read and run.
# Reporting vulnerabilities
> How to report a security vulnerability in Skytale components, and what response to expect.
Danger
**Please do NOT open a public GitHub issue for security vulnerabilities.**
Email **** with:
* Description of the vulnerability
* Steps to reproduce
* Affected component(s)
* Potential impact assessment
## Response timeline
[Section titled “Response timeline”](#response-timeline)
| Action | Timeframe |
| ------------------ | --------------------------- |
| Acknowledgment | 48 hours |
| Initial assessment | 5 business days |
| Status update | Every 7 days until resolved |
## Severity levels
[Section titled “Severity levels”](#severity-levels)
| Severity | Examples | Target resolution |
| -------- | -------------------------------------------------------- | ---------------------- |
| Critical | Remote code execution, key material exposure, MLS bypass | 72 hours |
| High | Authentication bypass, privilege escalation | 7 days |
| Medium | Information disclosure, denial of service | 30 days |
| Low | Minor issues, hardening improvements | Next scheduled release |
## Scope
[Section titled “Scope”](#scope)
All Skytale components are in scope:
* **skytale-relay**: QUIC/gRPC relay server
* **skytale-sdk**: Python SDK and Rust native extension
* **skytale-mls**: MLS encryption engine
* **skytale-net**: Iroh networking layer
* **skytale-store**: SQLCipher storage
* **API server**: REST API (accounts, keys, metering)
* **CLI**: command-line tool
## Security design principles
[Section titled “Security design principles”](#security-design-principles)
Skytale is built with these security properties:
* **End-to-end encryption**: all channel messages are encrypted using MLS (RFC 9420). The relay never sees plaintext.
* **Zero-knowledge relay**: relay nodes cannot decrypt channel data. They route ciphertext only.
* **Forward secrecy**: MLS epoch advancement provides forward secrecy; compromising current keys does not expose past messages.
* **Key zeroization**: all cryptographic key material in memory is zeroized on drop using the `zeroize` crate.
* **No plaintext logging**: message content and key material are never logged at any level, including TRACE.
## Supported versions
[Section titled “Supported versions”](#supported-versions)
| Version | Supported |
| ----------------- | ------------------- |
| Latest release | Yes |
| Previous releases | Security fixes only |
## Credit
[Section titled “Credit”](#credit)
We credit security researchers in release notes (with your permission). If you’d like to be credited, please include your preferred name and any relevant links in your report.
## Contact
[Section titled “Contact”](#contact)
* Security reports: ****
# Security model
> What is end-to-end encrypted, what the operator can see (including metadata), who holds the keys, and exactly what we claim versus what we do not.
The design never hands the operator your keys, so the operator cannot read your content. That is the whole trust story, and this page spells out its edges: what is encrypted and how, what the relay and directory necessarily see, who holds which keys, and the discipline that keeps our claims from drifting past the architecture.
## What is end-to-end encrypted
[Section titled “What is end-to-end encrypted”](#what-is-end-to-end-encrypted)
There are two encryption layers, one per job:
**Your own seat’s sync.** `tally push` seals log deltas with a sync key derived from your seed, using AES-256-GCM, before anything leaves the machine. What crosses the wire and what the relay stores is ciphertext. `tally pull` on another of your devices decrypts locally with the same seed-derived key. See [What syncs](/docs/tally/concepts/what-syncs/) for the full walk-through.
**Multi-party channels.** Rooms with more than one member use MLS (RFC 9420). Each channel is one MLS group; members derive shared keys through the group’s ratchet tree, and key material rotates on every membership change. By protocol design this gives forward secrecy (old epoch keys are deleted, so compromising a member today does not decrypt yesterday) and post-compromise security (a key update locks a past intruder out of the future). The relay carries MLS ciphertext and cannot join the group it is carrying.
In both layers, encryption and decryption happen in the client. The server-side components move opaque bytes.
## What the operator can see
[Section titled “What the operator can see”](#what-the-operator-can-see)
Be precise here, because “end-to-end encrypted” is not “invisible.” The operator (us, or whoever runs the relay you point at) necessarily sees:
* **Ciphertext**: the sealed batches and MLS messages themselves, opaque but present.
* **Channel names**: channels are registered in the directory under a readable `org/namespace/service` name, and the transport group-id is a SHA-256 of that name. Do not put secrets in channel names.
* **Timing and sizes**: when a seat published or fetched. The precise state: every tally send path (`tally push` batches, `tally msg`, rooms) pads its ciphertext to fixed size buckets (the `skytale-padding` crate: 8 buckets from 256B to 32KB, Padme for larger payloads), so the relay sees quantized sizes, not real ones. Long-lived connections (message watch, open rooms) additionally emit cover traffic: bucket-shaped random frames indistinguishable from real ones on the wire, on by default (`tally config set cover_traffic off` disables). What remains observable: THAT a short one-shot operation (a single push or pull) happened and when; cover flows only while a connection is open, so one-shot timing is not masked. We state that limit rather than claim more.
* **Account metadata**: the email you signed up with, hashed API keys, which channels your account registered, join requests and device grants, and revocation state.
* **Membership shape**: who requested to join which channel, because the directory brokers the join handshake.
What the operator cannot see, ever: the content of your log or messages, your seed, your sync key, or any MLS group key. Those exist only on member devices.
## Key custody
[Section titled “Key custody”](#key-custody)
* Your identity and every key derive from a **24-word BIP-39 recovery phrase** generated on your machine by `tally init`. The deliberate reveal ceremony is `tally backup`.
* Keys never leave your devices. They are not escrowed, not synced, not recoverable by us.
* The consequence, stated plainly: **losing the seed means losing the data.** There is no server-side reset, because a server that could reset your keys could also read your data. Do [the backup ceremony](/docs/tally/guides/backup-and-recovery/).
## Threat model
[Section titled “Threat model”](#threat-model)
What the design defends against:
* **Relay or directory compromise.** An attacker who owns the entire server side gets what the operator has: ciphertext and metadata. No content, no keys.
* **Stolen transport credentials.** The credential `tally connect` stores is channel-scoped and is a transport credential, not a decryption key. Holding it lets a party move sealed batches on one channel; it does not let anyone read them.
* **A dishonest operator.** The property is structural, not contractual. You do not have to trust our policy, because the architecture never gives us the material a policy would govern.
What the design does not defend against:
* **Your compromised device.** Plaintext and keys live on your machines. An attacker with your device and your session has what you have.
* **Metadata analysis.** As above: channel names, sizes, timing, and membership shape are visible to the operator and, in aggregate, say things.
* **Denial of service.** The operator, or an attacker who becomes the operator, can drop or withhold ciphertext. That is an availability failure, not a confidentiality one; self-hosting the relay is the escape hatch (see [Self-hosting the relay](/docs/skytale/self-host-relay/)).
## The two-level proof discipline
[Section titled “The two-level proof discipline”](#the-two-level-proof-discipline)
We separate two claims that the industry often blurs, and we hold the line between them:
**Level 1: structural end-to-end encryption. This is what we claim today.** The clients are open source, keys are generated and held client-side, and the server components handle only ciphertext. You can verify this yourself by reading the code and watching the wire: grep the stores for your plaintext, inspect what `tally push` emits, confirm the relay archive holds nothing decryptable. The property holds because of how the system is built, not because of who runs it.
**Level 2: attested execution (TEE). This is a different property, and we do not claim it.** Hardware attestation would let you verify what code a *remote* machine is running, which matters when an agent executes off your devices. Nothing in the current product runs your plaintext off your devices, so there is nothing to attest and no attestation claim to make. If a hosted execution tier ships later, it gets its own claims with its own proof, labeled separately.
The rule we enforce on ourselves: never use level-2 words for level-1 properties. The relay is structurally blind, not “attested.” If you catch these docs blurring that line, it is a bug; report it.
## Where next
[Section titled “Where next”](#where-next)
* [What syncs, and what the cloud can see](/docs/tally/concepts/what-syncs/): the sync path in precise detail
* [Backup and recovery](/docs/tally/guides/backup-and-recovery/): seed custody and the ceremony
* [The honesty ledger](/docs/shared/honesty-ledger/): the live record of claims we do and do not make
# What Skytale is
> Skytale is the open protocol under Tally: MLS (RFC 9420) channels for agents, carried by a relay that is structurally unable to read them.
Skytale is the open protocol under [Tally](/docs/tally/). It gives agents (and the people who own them) private, end-to-end encrypted channels using MLS (RFC 9420), carried by a zero-knowledge relay. The relay stores and forwards ciphertext; the keys live with the channel members. That is a structural property of the design, not a policy promise: the relay has nothing to decrypt with.
## The two planes
[Section titled “The two planes”](#the-two-planes)
A Skytale client talks to two services:
| Plane | Host | Role |
| --------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Directory and welcome | `https://api.skytale.sh` | Channel registration, invite tokens, the key-package directory, the Welcome handoff. |
| Messages | `https://relay.skytale.sh` | The zero-knowledge transport: a WebSocket for live publish/subscribe plus HTTP catch-up endpoints. Holds only ciphertext. |
Caution
The relay endpoint has no port: `https://relay.skytale.sh`. The legacy `:5000` form is plaintext-internal and will not work from outside.
Channels are MLS groups. Membership is enforced by the cryptography: you cannot decrypt a channel you were never added to, and the relay cannot decrypt any channel at all.
## Build on it
[Section titled “Build on it”](#build-on-it)
The full HTTP + WebSocket surface for writing your own client is documented in the [directory and relay API reference](/docs/skytale/directory-relay-api/). You do not need to implement any cryptography to evaluate the transport; the MLS engine ships in the SDK.
The Skytale relay and protocol are open source under Apache 2.0, so you can run your own relay and point clients at it. See [licensing](/docs/shared/licensing/) for how the pieces are licensed.
## Honesty note
[Section titled “Honesty note”](#honesty-note)
Skytale’s end-to-end encryption claim is a structural one: the FOSS relay never holds keys or plaintext, and you can read the code and the wire to check it. We do not claim hardware attestation or TEE properties for the relay; those are a separate class of guarantee and are not part of this surface.
# Architecture
> The two planes of Skytale: a directory API that brokers accounts and joins, and a relay that store-and-forwards ciphertext. Channels are MLS groups; keys stay in clients.
Skytale splits the server side into two planes, and neither one can read your messages. The **directory** (an HTTP API) handles accounts, channel registration, and the join handshake; the **relay** store-and-forwards ciphertext. Everything confidential (keys, group state, plaintext) lives in the clients. This page explains each plane, what it stores, and how the pieces fit.
## The two planes
[Section titled “The two planes”](#the-two-planes)
| Plane | Host | Role |
| ------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Directory** | `https://api.skytale.sh` | Accounts and API keys, channel registration, invite tokens, the key-package directory and Welcome handoff, and the device-grant flow. Auth is your account API key (`sk_live_...`) or a short-lived JWT minted from it. |
| **Relay** | `https://relay.skytale.sh` | The message plane. Store-and-forward for opaque ciphertext: a WebSocket for live publish and subscribe, plus two HTTP catch-up endpoints (`GET /v1/messages` for app-message backfill, `GET /v1/sync` for the full log including MLS commits). Auth is a Bearer JWT. |
Caution
The relay endpoint has no port: `https://relay.skytale.sh`. Older docs mention a `:5000` default; that port is internal and does not work from outside.
The split is the point. Coordination (who is allowed to join, where a Welcome gets handed off) needs a stateful service; carrying messages needs a pipe. Neither job needs the keys, so neither plane gets them.
## Channels are MLS groups
[Section titled “Channels are MLS groups”](#channels-are-mls-groups)
A channel is one MLS group (RFC 9420), exactly one group per channel. The channel name is a 3-part `org/namespace/service` string; the transport group-id is `SHA-256` of that name, computed identically by the relay and the SDK.
Membership is enforced cryptographically, not administratively. Joining means: the joiner publishes an MLS key package to the directory, the channel owner runs `add_member` client-side and publishes the resulting MLS Welcome back through the directory, and the joiner processes that Welcome to enter the group. The directory brokers this handshake but never parses the MLS bytes; key packages and Welcomes cross it as opaque blobs.
A consequence worth stating plainly: the relay does not gate reads by channel ownership. A valid JWT plus a well-formed channel name is enough to fetch a channel’s ciphertext. The real gate is the MLS group itself; you cannot decrypt a channel you were never added to. Ownership checks live in the directory plane, on the owner-only operations.
## What each plane stores
[Section titled “What each plane stores”](#what-each-plane-stores)
**The directory stores** (in Postgres):
* Account records and hashed API keys. Raw keys are shown once at mint and never stored.
* Channel registrations: the readable name and which account owns it.
* Invite tokens (hashed) and pending join requests: the joiner’s identity string plus its key package and, later, its Welcome, both opaque MLS bytes in transit. Completed requests are garbage-collected.
* Device grants, with the granted credential stored sealed (see [Authentication](/docs/skytale/authentication/)).
**The relay stores**: a per-channel archive of ciphertext entries with monotonic sequence numbers, so offline members can catch up. The archive is persisted on disk but bounded by a per-channel cap and a TTL (defaults: 1000 entries per channel, 7 days); evicted messages are gone. The relay holds no key material, no plaintext, and no MLS group state.
**The clients store**: everything that matters. Identity keys, MLS group state, and plaintext exist only on member devices.
## What the planes can see
[Section titled “What the planes can see”](#what-the-planes-can-see)
Both planes see metadata: channel names, who registered and joined what, message sizes and timing, account email addresses. Neither sees content or keys. The full honest accounting, including what metadata implies, is in [the security model](/docs/shared/security-model/).
## Building a custom client
[Section titled “Building a custom client”](#building-a-custom-client)
The whole client-facing surface is eight operations: five directory ops (register, invite, publish key package, Welcome handoff, ack) and three message ops (WebSocket publish/subscribe, message backfill, epoch sync). You bring an MLS engine; the planes bring coordination and transport. The complete endpoint reference, with request and response shapes and the end-to-end owner, joiner, and reconnect flows, is at [Directory and relay API](/docs/skytale/directory-relay-api/).
## Where next
[Section titled “Where next”](#where-next)
* [Directory and relay API](/docs/skytale/directory-relay-api/): the full endpoint reference for custom clients
* [Authentication](/docs/skytale/authentication/): API keys, JWTs, and the device-grant flow
* [Security model](/docs/shared/security-model/): what the operator can and cannot see, precisely
# Authentication
> The three credentials in Skytale: account API keys, short-lived relay JWTs, and channel-scoped device-grant keys. What each one can do and why the scoping matters.
Skytale has three credentials, each deliberately weaker than the last: an **account API key** (the root credential), a **short-lived JWT** (what the relay actually accepts), and a **channel-scoped device-grant key** (what a seat holds so it never needs your root key). None of them is a decryption key. Authentication in Skytale controls who can move ciphertext and operate the directory; reading content is controlled by MLS group membership and your seed, which no server holds.
This page explains each credential. The full endpoint reference lives at [Directory and relay API](/docs/skytale/directory-relay-api/).
## Account API keys
[Section titled “Account API keys”](#account-api-keys)
The root credential is an API key with the `sk_live_` prefix. Your first key is minted when the account is created: `POST /v1/accounts` is unauthenticated, takes an email, and returns the account plus its first key.
```sh
$ curl -s -X POST https://api.skytale.sh/v1/accounts \
-H "Content-Type: application/json" \
-d '{"email": ""}'
```
The raw key is returned exactly once, at mint. The server stores only a hash of it, so a lost key cannot be recovered, only replaced. Additional keys are minted with `POST /v1/keys` and revoked with `DELETE /v1/keys/{id}`, both authenticated with an existing key as a Bearer token.
The account key authenticates the directory surface: channel registration, invites, the owner side of the join handshake. Treat it like a password; the standard hygiene applies (environment variable or keychain, never version control).
## Relay JWTs
[Section titled “Relay JWTs”](#relay-jwts)
The relay does not accept API keys. Every relay request (the WebSocket, `GET /v1/messages`, `GET /v1/sync`) carries a short-lived JWT minted by the directory:
```sh
$ curl -s -X POST https://api.skytale.sh/v1/tokens \
-H "Authorization: Bearer sk_live_"
```
```txt
{"token":"eyJhbGciOiJIUzI1NiIs..."}
```
The token is HS256 with `iss: skytale-api`, `sub` = your account UUID, and an expiry the API caps at 300 seconds. Pass it as `Authorization: Bearer ` on HTTP, or in the first frame on the WebSocket. Clients re-exchange when it expires; the SDK does this for you.
Two honest notes on what a relay JWT authorizes:
* A valid JWT plus a well-formed channel name is sufficient to read or write any channel’s **ciphertext** on the relay. The relay has no account-to-channel table; the ownership gate lives in the directory, and the content gate lives in the MLS group itself (you cannot decrypt a channel you were never added to).
* The relay honors revocation: a revoked identity gets `403 Forbidden`.
## The device grant
[Section titled “The device grant”](#the-device-grant)
The device grant answers a specific problem: a device or agent seat needs to sync on its own channel, but handing it your account key would let it do everything your account can do, on every channel, forever. The grant gets a seat a **channel-scoped credential without the owner key ever touching the device**. `tally connect` is the consumer of this flow; it runs the grant and stores the resulting credential so `tally push` and `tally pull` work.
The flow is RFC-8628-style device authorization, three endpoints:
* `POST /v1/device/code` (unauthenticated): the device starts a request, submits its MLS key package upfront, and receives a `device_code` (secret, kept by the device) plus a short human-readable `user_code` (rendered `XXXX-XXXX`). The request expires after 10 minutes.
* `POST /v1/device/approve` (owner-authenticated): the owner approves the `user_code`, naming the channel and seat. Approval also files the device’s key package as a join request, so the owner’s normal add-member flow admits the seat into the MLS group. No credential is minted at approval.
* `POST /v1/device/token` (unauthenticated): the device polls with its `device_code` at the advised 5-second interval. On the first poll after approval, a **channel-scoped `sk_live_` key** is minted and returned. Re-polls return the same key; the poll is idempotent.
The scoping is enforced server-side: the minted key carries its channel, and the API refuses it on any other channel and on the account-wide surface. A leaked seat credential is a one-channel transport credential, not your account.
The mint-at-token design is also a storage property: the credential is minted at redemption and stored sealed under a key derived from the device’s own `device_code`, which the server keeps only as a hash. The database holds hashes and sealed bytes; a database compromise does not yield usable credentials.
A grant is revoked with `DELETE /v1/device/grant`, which kills the seat’s key. Removing the seat from the MLS group is the separate, cryptographic half of offboarding; do both.
## The chain, end to end
[Section titled “The chain, end to end”](#the-chain-end-to-end)
An owner key mints JWTs and approves grants. A grant mints a seat’s channel-scoped key. That scoped key mints JWTs that only make sense for one channel. And at the bottom of every chain, the thing being moved is ciphertext either way; the credential hierarchy bounds blast radius, and the encryption bounds what any credential is worth.
## Where next
[Section titled “Where next”](#where-next)
* [Directory and relay API](/docs/skytale/directory-relay-api/): every endpoint, with request and response shapes
* [Architecture](/docs/skytale/architecture/): the two planes these credentials authenticate to
* [tally connect](/docs/tally/reference/cli/connect/): the CLI consumer of the device grant
# Directory and relay API
> The external developer reference for the FOSS directory and relay HTTP/WebSocket API: build a custom client that joins an end-to-end-encrypted MLS channel.
This is the developer-facing reference for the **FOSS directory + relay API**: the HTTP + WebSocket surface that lets you build a custom client that joins an end-to-end-encrypted (MLS / RFC 9420) channel, where **the relay never sees plaintext**. It is the Stage-0 keystone of the open-core split: this surface is FOSS (protocol / relay / directory + welcome), and only the rich layer (roles beyond owner-approve, org policy, billing, the hosted-relay account gating) is commercial.
The 8-operation surface below already ships end-to-end in skytale. You do not need to build any crypto: the MLS engine (key packages, Welcomes, group operations, ciphertext) lives in the SDK; this document is about the **directory + transport** the relay and API expose around it.
For the FIRM contract (the locked op table the FE and orchestrator integrate against), see `docs/specs/2026-06-18-foss-directory-relay-api-design.md` in the skytale repo. This reference documents the **actually-shipped** endpoint shapes; where it adds detail beyond the firm contract (e.g. exact JSON field names, status codes), the shipped code is authoritative.
## The two hosts
[Section titled “The two hosts”](#the-two-hosts)
A channel client talks to two services:
| Host | Default URL | Role |
| --------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **API** | `https://api.skytale.sh` | The **directory + welcome** plane. Channel registration, invite tokens, the key-package directory, and the Welcome handoff. Postgres-backed; auth = your account API key (`sk_...`) or a minted Bearer JWT. |
| **Relay** | `https://relay.skytale.sh` | The **message** plane. The zero-knowledge transport: a WebSocket for live publish/subscribe, plus two HTTP catch-up endpoints (`GET /v1/messages`, `GET /v1/sync`). Holds only ciphertext; auth = a Bearer JWT. |
Caution
The relay endpoint has **no port**: `https://relay.skytale.sh`. The legacy `:5000` default is plaintext-internal and will not work from outside.
## Authentication
[Section titled “Authentication”](#authentication)
Every relay request and every directory request is authenticated with a **Bearer JWT** minted by the API account layer.
* The JWT is an HS256 token with `iss: skytale-api`, `aud: skytale-api`, a near-future `exp` (the API caps it at 300s), and `sub` = the account UUID. Your account API key (`sk_live_…`) is exchanged for one via the API (`POST /v1/tokens` → `{ token }`); the SDK does this for you. (The relay’s validator accepts either `skytale-api` or `skytale-relay` as the audience and requires `iss: skytale-api`.)
* **Relay auth bar (`/v1/messages`, `/v1/sync`, the WebSocket):** a **valid JWT + a well-formed `org/ns/svc` channel name** is sufficient to read or write any channel. This deliberately mirrors the relay’s existing gRPC `Subscribe` self-grant: **the relay has no account-to-channel table**, so it does not check channel ownership; that gate lives in the directory plane (the API) and in the MLS group itself (you cannot decrypt a channel you were never `add_member`’d to). The relay additionally **honors revocation**: if your `did:skytale:{sub}` has been revoked, relay endpoints return `403 Forbidden`.
* **Directory auth bar (`/v1/channels/*`):** the API’s `AuthContext` (your account API key or session). Owner-only operations (`GET /pending`, `POST /welcome`) additionally verify you **own** the channel.
Pass the token as `Authorization: Bearer ` on HTTP requests; on the WebSocket, send it in the first frame (see [Messages](#messages-the-relay-host)).
## Channel identity: names, group-ids, and credentials
[Section titled “Channel identity: names, group-ids, and credentials”](#channel-identity-names-group-ids-and-credentials)
**Channel name** is always a 3-component, non-empty `org/namespace/service` string (e.g. `acme/payments/agent-v1`). The API validates: max 256 bytes, only `[A-Za-z0-9/_.-]`, exactly 3 non-empty parts.
**Group-id** = `SHA-256("org/ns/svc")`. The hash is taken over the literal bytes `org`, `/`, `namespace`, `/`, `service` concatenated. This is computed identically by:
* the relay (`NameRegistry::hash_name`), and
* the SDK (`channel_id_from_name`, `sdk/src/channel.rs`).
The **creator** sets this group-id when creating the MLS group; the **joiner does NOT derive it independently**: it arrives inside the Welcome (`join_from_welcome`). Convergence is therefore guaranteed by construction. If you build your own client, derive the transport channel-id with the same `SHA-256("org/ns/svc")` and pass it to your `create_group(group_id)` on the create path; on the join path, take the group-id from the Welcome and do not re-derive.
**Identity** is a `did:` string passed **raw**. You hand the same string to **both** the `/v1/channels/join` request (the `identity` field, opaque TEXT, 1 to 256 chars) **and** the MLS engine constructor. The engine detects the `did:` prefix and wraps it into the MLS credential as CBOR for you (`build_identity`, `crates/skytale-mls-flutter/src/api/mls.rs`):
* `did:key:…` → a `DidKey` credential,
* any other `did:…` → a `DidWeb` credential,
* anything not starting with `did:` → a raw 32-byte key credential.
**Do not hand-roll the credential bytes.** For roster matching, the `identity` string you send to `/join` must be byte-identical to the credential URI the engine builds (i.e. the same raw `did:` string). MLS authenticates by the credential’s bound signature key, not by these identifier bytes, so the embedded pubkey is a deterministic per-device HKDF value; the identifier is for the roster, not for authentication.
## The directory + welcome ops (API host)
[Section titled “The directory + welcome ops (API host)”](#the-directory--welcome-ops-api-host)
All paths below are on `https://api.skytale.sh`. All bodies are JSON; all `key_package` / `welcome` fields are base64-encoded MLS bytes.
### Register a channel
[Section titled “Register a channel”](#register-a-channel)
`POST /v1/channels`: owner registers a channel (idempotent upsert per account).
```jsonc
// Request
{ "name": "acme/payments/agent-v1" }
// 201 Created
{ "id": "", "name": "acme/payments/agent-v1" }
```
### Create an invite token
[Section titled “Create an invite token”](#create-an-invite-token)
`POST /v1/channels/invites`: owner-only (must own the channel). Mints the `skt_inv_…` token a joiner uses to publish a key package.
```jsonc
// Request
{ "channel": "acme/payments/agent-v1", "max_uses": 1, "ttl_seconds": 3600 }
// 201 Created
{ "token": "skt_inv_…", "expires_at": "" }
```
`max_uses` defaults to 1 (range 1 to 100); `ttl_seconds` defaults to 3600 (range 60 to 604800, i.e. 1 minute to 7 days).
### Publish a key package (op 1, publishKeyPackage)
[Section titled “Publish a key package (op 1, publishKeyPackage)”](#publish-a-key-package-op-1-publishkeypackage)
`POST /v1/channels/join`: a would-be member submits its MLS key package + identity, gated by an invite token. Cross-organization joins pass a `federation_token` instead of (or in addition to) the standard invite path.
```jsonc
// Request
{
"channel": "acme/payments/agent-v1",
"token": "skt_inv_…", // standard invite token
"key_package": "",
"identity": "did:web:agent.example.com",
"federation_token": "skt_fed_…" // OPTIONAL: cross-org join (bypasses normal invite validation)
}
// 201 Created
{ "request_id": "" }
```
Validation: key package ≤ 64 KB; identity 1 to 256 chars; the channel must have fewer than 1000 pending requests. When `federation_token` is present, the API validates it against the `federation_invites` table (consuming one use, org-matched) and proceeds through the same downstream flow.
### Fetch pending key packages (op 2, fetchKeyPackages)
[Section titled “Fetch pending key packages (op 2, fetchKeyPackages)”](#fetch-pending-key-packages-op-2-fetchkeypackages)
`GET /v1/channels/pending?channel=acme/payments/agent-v1`: **owner-only** (must own the channel). Returns the queue of join requests awaiting approval.
```jsonc
// 200 OK - NOTE: an object with a `requests` array, NOT a bare array
{
"requests": [
{
"id": "", // the request_id to pass to /welcome
"identity": "did:web:agent.example.com",
"key_package": "",
"created_at": ""
}
]
}
```
Ordered ascending by `created_at`. An empty queue is `{ "requests": [] }`.
### Publish a Welcome (op 3, publishWelcome)
[Section titled “Publish a Welcome (op 3, publishWelcome)”](#publish-a-welcome-op-3-publishwelcome)
`POST /v1/channels/welcome`: **owner-only**. After the owner runs `add_member` with a pending key package, it uploads the resulting MLS Welcome, which marks the request `completed`.
```jsonc
// Request
{ "request_id": "", "welcome": "" }
// 200 OK
{ "status": "completed", "welcome": null }
```
Welcome ≤ 256 KB. Returns `404 Not Found` if the request is not pending or the caller does not own the channel.
### Poll for a Welcome (op 4, fetchWelcomes)
[Section titled “Poll for a Welcome (op 4, fetchWelcomes)”](#poll-for-a-welcome-op-4-fetchwelcomes)
`GET /v1/channels/welcome/{request_id}`: the **joiner** polls its own request (scoped to the caller’s account) until the Welcome is ready.
```jsonc
// 200 OK while waiting
{ "status": "pending" }
// 200 OK once the owner has submitted
{ "status": "completed", "welcome": "" }
```
`welcome` is omitted until `status == "completed"`. `404 Not Found` if the request id does not belong to the caller.
### Acknowledge + GC a completed Welcome (op 5, ackWelcome)
[Section titled “Acknowledge + GC a completed Welcome (op 5, ackWelcome)”](#acknowledge--gc-a-completed-welcome-op-5-ackwelcome)
`POST /v1/channels/welcome/{request_id}/ack`: the **joiner**, after it has fetched its Welcome and joined the group, drops the now-useless request row.
```jsonc
// 200 OK
{ "acked": true }
```
Scoped to the caller’s own account **and** to `status = 'completed'`, so it can never delete another account’s request or an in-flight one. **Idempotent:** a second call (or a request that no longer exists) returns `{ "acked": false }`. A background sweep on the API also GCs completed/expired requests after 24h, so calling `ack` is an optimization, not a correctness requirement.
## Messages (the relay host)
[Section titled “Messages (the relay host)”](#messages-the-relay-host)
All message I/O is on `https://relay.skytale.sh`. Payloads are **always base64-encoded MLS ciphertext**; the relay cannot read them.
### Live transport: the WebSocket (op 6 postMessage + op 7 fetchMessages, live)
[Section titled “Live transport: the WebSocket (op 6 postMessage + op 7 fetchMessages, live)”](#live-transport-the-websocket-op-6-postmessage--op-7-fetchmessages-live)
Connect to `wss://relay.skytale.sh/ws`. The protocol is JSON frames, tagged by a `type` field:
1. **Auth (first frame, required):**
```json
{ "type": "auth", "token": "" }
```
The server replies `{ "type": "ok", "message": "authenticated" }` on success, or `{ "type": "error", "message": "…" }` and closes on failure. You have 10s to send auth; the first frame MUST be auth.
2. **Subscribe** (required before you can publish to a channel):
```json
{ "type": "subscribe", "channel": "acme/payments/agent-v1" }
```
→ `{ "type": "ok", "message": "subscribed to acme/payments/agent-v1" }`. Max 50 channels per connection.
3. **Publish** (op 6, postMessage):
```json
{ "type": "publish", "channel": "acme/payments/agent-v1", "payload": "" }
```
You must be subscribed to the channel first. Payload ≤ 1 MB (decoded). → `{ "type": "ok", "message": "published" }`.
4. **Server push** (op 7, fetchMessages, live): the relay pushes every message on a subscribed channel as:
```json
{ "type": "message", "channel": "acme/payments/agent-v1", "payload": "" }
```
5. **Unsubscribe:** `{ "type": "unsubscribe", "channel": "…" }`.
The WebSocket is **live-only**: it does NOT replay history. A client that was offline uses the two HTTP catch-up endpoints below, then resumes the WebSocket.
### Cold-open backfill (op 7, fetchMessages, history): `GET /v1/messages`
[Section titled “Cold-open backfill (op 7, fetchMessages, history): GET /v1/messages”](#cold-open-backfill-op-7-fetchmessages-history-get-v1messages)
`GET /v1/messages?channel=acme/payments/agent-v1&since_seq=&limit=`: forward (ascending) scan of **app messages only** (entry\_type 0) from the archive. This is the cold-open history path: a brand-new client with no local state replays the channel’s decryptable ciphertext.
```jsonc
// 200 OK - NOTE: an object with `messages` + `has_more`, NOT a bare array
{
"messages": [
{ "seq": 1, "payload": "", "ts": 1718000000000 }
],
"has_more": false
}
```
* `seq` is the per-channel monotonic sequence number, **the pagination cursor**. `ts` is the Unix-millisecond archive time.
* `since_seq` (default 0) returns only entries with `sequence > since_seq`. `since_seq=0` is a full cold-open from the start of retained history.
* `limit` defaults to 100, **hard-capped at 500**.
* **Paging:** re-request with `since_seq = messages.last().seq` while `has_more == true`. `has_more` is **computed server-side**; do not infer pagination from `messages.len() == limit`. (That inference is broken by the limit cap: a client requesting more than 500 gets a full-but-capped page whose length never equals the request, and would wrongly conclude it was caught up.)
* “Full history” is bounded by the per-channel archive ring-buffer cap + TTL; evicted messages are gone.
### Epoch catch-up: `GET /v1/sync`
[Section titled “Epoch catch-up: GET /v1/sync”](#epoch-catch-up-get-v1sync)
`GET /v1/sync?channel=acme/payments/agent-v1&since_seq=&limit=`: forward scan of the **FULL log**, ALL entry types, including MLS commits and Welcomes. An offline member uses this on reconnect to **advance its MLS epoch**: `/v1/messages` (app-only) cannot, because it filters out the commits the member missed.
```jsonc
// 200 OK
{
"entries": [
{ "seq": 1, "entry_type": 0, "payload": "", "ts": 1718000000000 },
{ "seq": 2, "entry_type": 1, "payload": "", "ts": 1718000000001 },
{ "seq": 3, "entry_type": 2, "payload": "", "ts": 1718000000002 }
],
"has_more": false
}
```
* `entry_type`: **0 = app message, 1 = commit, 2 = Welcome.**
* On reconnect, feed every `entry_type == 1` (commit) entry to your MLS engine (`process_message`) to advance the epoch, and treat `entry_type == 0` entries as chat. (Entry-type 2 Welcomes are present for completeness; a member already in the group does not consume them.)
* Same `since_seq` / `limit` (default 100, cap 500) / server-computed `has_more` semantics as `/v1/messages`, and the same paging rule (`since_seq = entries.last().seq` while `has_more`).
* Same auth bar (valid JWT + 3-part channel + revocation check).
Note
**Why two endpoints?** `/v1/messages` is the app-only ciphertext replay for chat history (a cold-open client). `/v1/sync` is the full-log replay including the membership commits an offline member needs to fix its epoch. A reconnecting member uses `/v1/sync` (to converge its MLS state) and may use `/v1/messages` for chat-history UX.
## End-to-end client flows
[Section titled “End-to-end client flows”](#end-to-end-client-flows)
There are three roles a client plays. The Python SDK `SkytaleChannelManager` (`sdk/python/skytale_sdk/channels.py`) and the Rust `SkytaleClient` (`sdk/src/client.rs`) are the reference implementations; port or wrap their patterns.
### Owner (creator): create + run an add-loop
[Section titled “Owner (creator): create + run an add-loop”](#owner-creator-create--run-an-add-loop)
The owner **must be online** to admit joiners (it is the only party that can run `add_member` and produce Welcomes).
1. `create_channel("org/ns/svc")` → builds the MLS group with group-id `SHA-256("org/ns/svc")`, subscribes on the relay.
2. `POST /v1/channels` to register the channel in the directory.
3. Run the **add-loop** (the SDK does this on a background thread, `_join_poll_loop`):
* `GET /v1/channels/pending?channel=…` →
* take **one** request per cycle (rate-limits MLS epoch advancement), `base64-decode` its `key_package`, call `add_member(key_package)` → produces a Welcome (and a commit that fans out to existing members over the relay) →
* `POST /v1/channels/welcome { request_id, welcome }`.
The reference loop processes at most one join per channel per poll cycle, exactly so that N joiners produce N sequential single-add commits rather than a burst.
### Joiner: generate KP → publish → poll → join
[Section titled “Joiner: generate KP → publish → poll → join”](#joiner-generate-kp--publish--poll--join)
1. `generate_key_package()` → an MLS key package; base64-encode it.
2. `POST /v1/channels/join { channel, token, key_package, identity }` → `{ request_id }`. (Use `federation_token` for a cross-org join.)
3. Poll `GET /v1/channels/welcome/{request_id}` until `status == "completed"`; base64-decode `welcome`.
4. `join_from_welcome(welcome)` (the SDK’s `join_channel(name, welcome)`) → joins the MLS group (group-id arrives in the Welcome, do **not** re-derive), subscribes on the relay.
5. Optionally `POST /v1/channels/welcome/{request_id}/ack` to GC the request.
### Reconnect (returning member): sync, then resume
[Section titled “Reconnect (returning member): sync, then resume”](#reconnect-returning-member-sync-then-resume)
A member with local MLS state that was offline:
1. `GET /v1/sync?channel=…&since_seq=` → feed `entry_type == 1` commit entries to the MLS engine (epoch advance) and `entry_type == 0` entries to chat; page until `has_more == false`.
2. Resume the WebSocket (`subscribe` + live `publish`/`message`).
3. For cold-open chat **history** UX (a fresh client with no local state), `GET /v1/messages` instead.
## The N-party shape
[Section titled “The N-party shape”](#the-n-party-shape)
Adding N members is **sequential single adds, not a batched commit**: each joiner publishes its own join request; the owner’s add-loop admits them one per cycle; the relay fans out each Add-commit so existing members stay in epoch sync. This is the same shape the live dev mesh runs (one MLS group, 5 members). The skytale test suite includes an N>2 group-convergence + fan-out integration test (`sdk/tests/group_convergence.rs`) that exercises owner-creates → N-joiners-add-loop → owner-sends → all N receive.
## Operation summary
[Section titled “Operation summary”](#operation-summary)
| # | Operation | Surface |
| - | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | publishKeyPackage | `POST /v1/channels/join` `{channel, token, key_package, identity, federation_token?}` → `{request_id}` (API) |
| 2 | fetchKeyPackages | `GET /v1/channels/pending?channel=…` → `{requests:[{id, identity, key_package, created_at}]}` (API, owner-only) |
| 3 | publishWelcome | `POST /v1/channels/welcome` `{request_id, welcome}` → `{status, welcome}` (API, owner-only) |
| 4 | fetchWelcomes | `GET /v1/channels/welcome/{request_id}` → `{status, welcome?}` (API, joiner) |
| 5 | ackWelcome | `POST /v1/channels/welcome/{request_id}/ack` → `{acked}` (API, joiner; GC) |
| 6 | postMessage | relay WebSocket `{type:"publish", channel, payload}` |
| 7 | fetchMessages | live: relay WebSocket server-push `{type:"message", channel, payload}` · cold-open backfill: `GET /v1/messages?channel=&since_seq=&limit=` → `{messages:[{seq, payload, ts}], has_more}` (relay, app-only) |
| 8 | syncLog | `GET /v1/sync?channel=&since_seq=&limit=` → `{entries:[{seq, entry_type, payload, ts}], has_more}` (relay, full log incl. commits, epoch-catchup) |
## See also
[Section titled “See also”](#see-also)
* The FIRM contract: `docs/specs/2026-06-18-foss-directory-relay-api-design.md` in the skytale repo.
* Reference clients: `sdk/python/skytale_sdk/channels.py` (Python `SkytaleChannelManager`), `sdk/src/client.rs` + `sdk/src/channel.rs` (Rust `SkytaleClient`).
* Shipped endpoint code: `api/src/routes/channels.rs` (directory + welcome), `relay/src/main.rs` (`/v1/messages`, `/v1/sync`), `relay/src/websocket.rs` (the WebSocket).
# Self-hosting the relay
> Run the whole connective tier yourself: one command brings up the open relay and API, and tally connect, push, pull, and rooms work against it.
The short version, stated up front: you can self-host the whole connective tier. The open repo ships a `selfhost/` bootstrap that brings up the relay, the API, and Postgres with one command, and a tally seat pointed at it provisions, syncs, and runs rooms end to end. This page shows that path, and it still says plainly what the packaged experience does not cover.
This page is the trust escape hatch, so it holds itself to the same discipline as [the security model](/docs/shared/security-model/): what works is shown, what does not work is said plainly.
## What self-hosting buys, and what it does not
[Section titled “What self-hosting buys, and what it does not”](#what-self-hosting-buys-and-what-it-does-not)
Confidentiality does not depend on who runs the relay. The relay handles ciphertext whether we run it or you do; that is the structural property, and it is already yours on the hosted relay. What self-hosting removes is your dependence on us for **availability** (nobody can withhold your ciphertext) and **metadata** (channel names, sizes, timing stop flowing through our infrastructure). Those are real reasons, and they are the honest ones.
Licensing, factually: the protocol, relay, SDK, and kernel crates are Apache 2.0. The API service directory (`api/`) is BSL 1.1 with a grant that expressly allows exactly this: self-hosting for yourself or your organization. What the BSL forbids is offering it to third parties as a hosted or managed service. Details on [the licensing page](/docs/shared/licensing/).
## The one command
[Section titled “The one command”](#the-one-command)
From the open source tree:
```sh
$ cd skytale/selfhost
$ ./up.sh
```
The script generates the shared secrets once (JWT secret linking API to relay, an internal secret, a database password; all kept out of git), renders the two config files from their examples, builds the release binaries, provisions a private Postgres cluster, starts relay and API, runs the database migrations, and waits for both health endpoints. `./down.sh` reverses all of it. A `--docker` variant with a compose file exists; it is authored but not yet runtime-verified on our side, and the script path is the proven one.
Then point a tally seat at your stack:
```sh
$ tally config set api_url http://127.0.0.1:3100
$ tally config set relay_url http://127.0.0.1:8443
$ tally signin
$ tally connect
```
Explicit `--api-url`/`--relay-url` flags and the `SKYTALE_API_URL` environment variable override the config keys when present (flag beats env beats config).
What has been verified end to end against exactly this stack, with nothing pointing at skytale.sh: account mint, the full device-grant provisioning flow, `tally push` and `tally pull` restoring a log onto a second home, and a two-seat room (create, invite, join, admit, messages decrypting in both directions). The relay archive was checked afterward: ciphertext only.
One honest note on that flow: a second device with the same identity recovers via `tally signin --recover` plus copying the relay credential file; a same-identity second `connect` is refused by the seat-uniqueness guard by design.
## The relay itself
[Section titled “The relay itself”](#the-relay-itself)
The open relay serves the WebSocket at both shapes: bare `/ws` with subscribe frames (the historical surface for custom clients) and per-channel `/ws/` (the shape tally speaks, matching the hosted relay), plus HTTP catch-up at `GET /v1/messages` and `GET /v1/sync` and a bounded per-channel ciphertext archive. The one setting that matters most is `jwt_secret`: when set, the relay requires a valid HS256 Bearer JWT on the WebSocket and catch-up endpoints; the bootstrap generates and wires this for you.
```sh
$ curl http://localhost:8443/health
```
```txt
{"status":"ok","uptime":42}
```
## What the packaged experience does not cover
[Section titled “What the packaged experience does not cover”](#what-the-packaged-experience-does-not-cover)
* **TLS is your reverse proxy.** The bootstrap serves plain HTTP on loopback, which is also the only plain-HTTP mode the tally client permits. Anything beyond one machine needs your proxy terminating TLS in front of both services.
* **No signup email, no billing.** The self-hosted API mints accounts directly; email verification and Stripe routes are non-functional behind placeholder keys, deliberately.
* **The hosted directory will not validate your tokens.** Your stack signs JWTs with your own secret; the hosted `api.skytale.sh` signs with ours. The two do not mix, by design.
Note
No real infrastructure details appear on this page by design. Ports, hosts, and hardening choices for your deployment are yours; the configs document their own knobs.
## Where this leaves you
[Section titled “Where this leaves you”](#where-this-leaves-you)
* **Custom clients over your own relay: works today**, from the open source, with your own JWT minting.
* **Tally seat sync and rooms over your own stack: works today**, via the `selfhost/` bootstrap and the flags shown above, verified end to end.
## Where next
[Section titled “Where next”](#where-next)
* [Directory and relay API](/docs/skytale/directory-relay-api/): the surface a self-hosted stack serves
* [Architecture](/docs/skytale/architecture/): the two planes and what each stores
* [Security model](/docs/shared/security-model/): why confidentiality never depended on who runs the relay
# What Tally is
> Tally gives your AI agent a home: a durable identity, an encrypted memory, and a history that survive the session, the harness, and the vendor.
Tally gives your AI agent a home: a durable identity, an encrypted memory, and a history that survive the session, the harness, and the vendor. Run the same agent under Claude Code today and Codex tomorrow. The story continues; only the brains change.
Tally is the client. Skytale is the open protocol it runs on.
the one motion
```txt
$ tally start coder --claude-code --fable-5
[ ok ] coder is new here · fresh session
... you work ...
[ log ] session captured on exit · encrypted · seq 12
$ tally continue coder --codex --gpt-5.5
[ log ] resumed from seq 12 · same agent · new brains
```
## What it does
[Section titled “What it does”](#what-it-does)
* **Own.** `tally init` derives your agent’s identity from a 24-word seed only you hold. Everything the agent is lives in a local, AES-256-GCM encrypted, hash-tree verified log. Not in our account system. There isn’t one to be in.
* **Capture.** When a harness session ends, its context is captured into the log automatically. Launch the same agent twice from one context and the log branches like git history, and verifies like it too.
* **Launch.** [`continue` / `start` / `run`](/docs/tally/guides/continue-start-run/) put the agent under any installed harness with any valid model. Impossible pairs (an OpenAI model on claude-code) are rejected before anything is written.
* **Sync.** `push` / `pull` move the log between machines as ciphertext, sealed with your seed before it leaves the machine. The relay, ours or yours, reads nothing. See [what syncs, and what the cloud can see](/docs/tally/concepts/what-syncs/).
* **Connect.** `msg` / `channel` are private rooms: MLS (RFC 9420), member-keyed, operator-blind. Your agents can message each other like a team; the wire refuses to gossip.
* **Manage.** `tally ui` is mission control: the roster with an honest liveness watch (idle shows idle, dead shows dead, nothing is ever faked), the story picker with branch markers, launches that open in their own tmux windows while the TUI stays up.
* **Extend.** Three harness adapters ship built in (claude-code, pi, codex). Any other harness is a declarative TOML adapter away. See [building adapters](/docs/tally/adapters/building-adapters/) and the [PAC spec](/docs/tally/adapters/pac-spec/).
Note
Community adapters reach the session verbs (`session export` / `session materialize`) in the beta. Launching a harness (`continue` / `start` / `run`) works with the three built-ins only.
## Install
[Section titled “Install”](#install)
One line, no sudo, SHA-256 verified (Linux x86\_64 today; the [install page](/docs/tally/install/) has the honest platform matrix and the verify step):
```sh
$ curl -fsSL https://skytale.sh/install.sh | sh
```
Then:
```sh
# identity + harness check + relay connect
$ tally init
# setup checks, each with a fix line
$ tally doctor
```
Continue with the [quickstart](/docs/tally/quickstart/).
## Honesty notes
[Section titled “Honesty notes”](#honesty-notes)
We document exactly what the code does:
* The log is encrypted at rest and on the wire; `tally status` re-verifies the hash chain on demand.
* Liveness in the TUI is derived from real activity recency. There is no state we invent.
* A portable context is an orientation digest (task, plan, files, repo state), not a byte-for-byte replay of another harness’s session. Continuity is real; capabilities still belong to the harness you land in, and the CLI says so when you swap.
* Cross-device sync works today against a configured relay. The relay retains your pushed ciphertext hosted-side as a bounded catch-up buffer (days, not forever); a durable hosted save tier is not live yet.
When a page here disagrees with the binary, the binary wins; the [CLI reference](/docs/tally/reference/cli/) is generated from its help text.
# adapter.toml reference
> The Level-1 declarative adapter manifest: every field, the {home}-only interpolation rule, and the shell-pattern scan.
A Level-1 adapter is a single `adapter.toml` manifest. It is pure data, never executed; a generic engine interprets it to find, extract, and materialize JSONL sessions. This page is the schema reference; the workflow around it (create, validate, install, publish) is in [Building adapters](/docs/tally/adapters/building-adapters/), and the contract it implements is [the PAC specification](/docs/tally/adapters/pac-spec/).
## A complete manifest
[Section titled “A complete manifest”](#a-complete-manifest)
```toml
[adapter]
name = "myharness" # registry name, [a-z0-9-]+
harness_id = "myharness" # provenance id written into portable contexts
version = "0.1.0"
author = "you"
description = "myharness session adapter"
[session]
# Where the harness keeps its sessions. {home} (the user home dir) is the
# ONLY interpolation; no env vars, no command substitution, by design.
root = "{home}/.myharness/sessions"
glob = "**/*.jsonl" # ** spans directories, * stays in one component
cwd_field = "/cwd" # JSON pointer into the FIRST line (the header)
[extract]
session_id_field = "/session_id" # header pointer; file stem when absent
role_field = "/role" # per-line pointer to the message role
user_role = "user" # the role value marking a user message
content_field = "/content" # the first user line's content = the task
[materialize]
# Templates for the forged session, one JSON object per line. Placeholders:
# {session_id} {cwd} {task} {digest} {ts_ms}. String values are JSON-escaped
# on insert, so keep them inside quotes ({ts_ms} is a bare number). The
# header line MUST carry {cwd} at cwd_field or the round-trip fails.
lines = [
'{"session_id":"{session_id}","cwd":"{cwd}","ts":{ts_ms}}',
'{"role":"user","content":"{task}"}',
'{"role":"assistant","content":"{digest}"}',
]
```
## `[adapter]`
[Section titled “\[adapter\]”](#adapter)
| Field | Meaning |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `name` | The adapter’s registry name, `[a-z0-9-]+`. This is what you pass to `session export --harness` and `session materialize --into`. |
| `harness_id` | The provenance id written into portable contexts this adapter captures. |
| `version` | The manifest version string. |
| `author` | Who wrote it. |
| `description` | One line describing the adapter. |
## `[session]`
[Section titled “\[session\]”](#session)
| Field | Meaning |
| ----------- | -------------------------------------------------------------------------------------------------------- |
| `root` | Where the harness keeps its sessions. |
| `glob` | The session file pattern under `root`. `**` spans directories; `*` stays in one path component. |
| `cwd_field` | A JSON pointer into the FIRST line of the file (the header) that yields the session’s working directory. |
Note
**`{home}` is the ONLY interpolation** in `root`: it expands to the user home directory. No environment variables, no command substitution, by design. The manifest stays inert data.
## `[extract]`
[Section titled “\[extract\]”](#extract)
| Field | Meaning |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| `session_id_field` | JSON pointer into the header line for the session id; the file stem is the fallback when absent. |
| `role_field` | Per-line JSON pointer to the message role. |
| `user_role` | The role value that marks a user message. |
| `content_field` | JSON pointer to the content of the first user line; that content becomes the task. |
## `[materialize]`
[Section titled “\[materialize\]”](#materialize)
`lines` is a list of templates for the forged session, one JSON object per line. Available placeholders: `{session_id}`, `{cwd}`, `{task}`, `{digest}`, `{ts_ms}`. String values are JSON-escaped on insert, so keep them inside quotes; `{ts_ms}` is a bare number. The header line MUST carry `{cwd}` at `cwd_field`, or the round-trip fails.
## How the engine uses the manifest
[Section titled “How the engine uses the manifest”](#how-the-engine-uses-the-manifest)
* **find**: newest file under `root` matching `glob` whose header line’s `cwd_field` equals the working directory.
* **extract**: session id from the header (file stem fallback); the task is the first line whose `role_field` equals `user_role`, read via `content_field`. Plan and file lists are honest-empty at Level 1: a manifest cannot describe tool-call schemas.
* **materialize**: renders the `lines` templates with the context’s values and writes the forged session under `root` (into the glob’s leading literal directories, so the manifest’s own glob finds it).
`tally adapter validate` proves the manifest with the PAC conformance smoke: it materializes a marker context into a temp directory, extracts it back, and asserts the task is recovered verbatim, plus per-line JSON validity and the find path.
## The shell-pattern scan
[Section titled “The shell-pattern scan”](#the-shell-pattern-scan)
The manifest is pure data and is never executed, but `tally adapter install` refuses manifests containing shell-exec patterns unless you pass `--allow-shell` after reviewing the manifest. The refused patterns: `$(`, backticks, `| sh`, `| bash`, `&&`, `curl`, and `;` inside templates.
Caution
Installed community adapters are marked “community, unverified” and reach the session verbs only (`session export` / `session materialize`); the launch verbs (`continue` / `start` / `run`) stay built-ins-only for beta. See [The adapter registry](/docs/tally/adapters/registry/).
# Building adapters
> How to teach tally a new harness: the Level-1 declarative manifest workflow and the Level-2 native adapter path.
An adapter teaches tally to move portable context in and out of a harness’s native session store. There are two levels; pick the smallest one that fits. The contract both levels implement is [the PAC specification](/docs/tally/adapters/pac-spec/).
## Level 1: a declarative manifest (most harnesses)
[Section titled “Level 1: a declarative manifest (most harnesses)”](#level-1-a-declarative-manifest-most-harnesses)
If your harness stores sessions as **JSONL files** (one JSON object per line, a header line carrying the working directory), you do not write any code. You write an `adapter.toml` and the generic engine does the rest. The full manifest schema lives in the [adapter.toml reference](/docs/tally/adapters/adapter-toml-reference/).
### Workflow
[Section titled “Workflow”](#workflow)
```plaintext
tally adapter create myharness # scaffolds ./tally-adapter-myharness/adapter.toml
$EDITOR tally-adapter-myharness/adapter.toml
tally adapter validate ./tally-adapter-myharness # schema + round-trip conformance
tally adapter install ./tally-adapter-myharness # or install from a git URL
tally session export --harness myharness # it now works
tally session materialize --into myharness
```
Share it by publishing the directory as a git repo; anyone installs it with `tally adapter install `. A community registry, searchable via `tally adapter search`, launches with the beta; the format is described in [The adapter registry](/docs/tally/adapters/registry/).
### Validation
[Section titled “Validation”](#validation)
`tally adapter validate` proves the manifest with the PAC conformance smoke: it materializes a marker context into a temp directory, extracts it back, and asserts the task is recovered verbatim, plus per-line JSON validity and the find path. Fix the reported knobs until every check reads PASS.
### Security posture and what community adapters can do
[Section titled “Security posture and what community adapters can do”](#security-posture-and-what-community-adapters-can-do)
The manifest is pure data and is never executed, but `install` refuses manifests containing shell-exec patterns unless you pass `--allow-shell` after reviewing it (the pattern list is in the [adapter.toml reference](/docs/tally/adapters/adapter-toml-reference/#the-shell-pattern-scan)).
Caution
Installed community adapters are marked “community, unverified” and reach the **session verbs only** (`session export --harness ` / `session materialize --into `). The launch verbs (`continue` / `start` / `run`) stay built-ins-only for beta, because their harness+model combo validation cannot cover unknown harnesses.
## Level 2: a native Rust adapter (exotic harnesses)
[Section titled “Level 2: a native Rust adapter (exotic harnesses)”](#level-2-a-native-rust-adapter-exotic-harnesses)
If the harness’s store is not flat JSONL (SQLite, tree-linked events, date-partitioned rollouts, custom text), write a Rust module implementing the same three operations and wire it in as a built-in. The three in-tree adapters are the reference implementations, in increasing order of format complexity:
* `src/portable/pi.rs`: parent-linked JSONL events, header + typed messages.
* `src/portable/claude_code.rs`: unversioned JSONL with many event types, a tolerant-by-construction parser, plan extraction from tool calls.
* `src/portable/codex.rs`: date-partitioned rollout files, payload-wrapped messages.
The contract each implements:
```rust
pub const HARNESS: &str = "..."; // provenance id
pub fn find_latest_session_file(root: &Path, cwd: &str) -> Result;
pub fn extract_from_file(path: &Path) -> Result;
pub fn materialize(root: &Path, cwd: &str, ctx: &PortableCtx) -> Result<(String, PathBuf), CliError>;
```
House rules for a Level-2 adapter, learned from the first three:
* Build from **direct inspection of the public on-disk format**, never from a third-party converter.
* Parse **tolerantly**: skip unknown event types and unparseable lines; session formats grow new event types without notice.
* Extraction is **deterministic**, no model in the loop, and **honest**: fields the format does not carry stay empty rather than being invented.
* Prove acceptance with a **live spike**: forge a minimal session and confirm the real harness resumes it before shipping the adapter.
* Test with the same round-trip property the Level-1 conformance smoke checks: materialize then extract must recover the task.
Open a PR adding the module under `src/portable/` plus its wiring; the spec for the layer is [the PAC specification](/docs/tally/adapters/pac-spec/).
## See also
[Section titled “See also”](#see-also)
* [adapter.toml reference](/docs/tally/adapters/adapter-toml-reference/): every manifest field, the interpolation rule, the shell-pattern scan
* [The adapter registry](/docs/tally/adapters/registry/): the community registry format and search
* [The PAC specification](/docs/tally/adapters/pac-spec/): the contract adapters implement
# The PAC specification
> PAC (Portable Agent Context) v0.1 draft: the open contract behind tally's cross-harness portability.
Note
**Status: draft for iteration (spec v0.1).** This document describes the open contract that tally’s cross-harness portability implements. It is written to stand alone: an adapter author needs nothing but this page, the [Building adapters](/docs/tally/adapters/building-adapters/) guide, and the three in-tree reference adapters.
## What PAC is
[Section titled “What PAC is”](#what-pac-is)
PAC is an open standard for **portable agent context**: the agent’s durable self (task, plan state, history pointers, provenance, identity references) round-tripping through *any* coding-agent harness. It defines a **canonical Portable Context format** and a **per-harness adapter contract** that materializes that context INTO a harness’s native session format and captures new context OUT, so the same agent runs under any harness or model without losing its accumulated state. It complements existing standards: ACP, MCP, and A2A handle steering and transport; PAC handles the one layer none of them touch: owned, portable, round-trip state.
## The problem
[Section titled “The problem”](#the-problem)
Every coding harness (Claude Code, Codex, pi, Aider, OpenHands, Gemini CLI, Goose, OpenCode, Cursor) stores the agent’s session in its own bespoke format (tree-JSONL, flat JSONL, SQLite, rollouts, opaque chat IDs). No harness can read another’s state, and the major agent SDKs each ship vendor-internal session models with no cross-vendor portability. The result: an agent’s accumulated self is hostage to whichever harness produced it. PAC closes that gap.
## The contract: six operations
[Section titled “The contract: six operations”](#the-contract-six-operations)
A PAC adapter for a harness implements six operations, split into two layers.
**Steering** (rides the de-facto CLI/JSONL contract every harness speaks):
1. **spawn**: launch the harness with an agent identity, a model designation, and a bound session.
2. **model**: designate the brain (flag/env/config varies per harness; the adapter normalizes).
3. **lifecycle**: run, suspend, resume, fork, cancel, exit (where the harness supports it).
**Portability** (PAC’s novel contribution):
4. **materialize IN**: translate the canonical Portable Context into the harness’s native session format and write it to the harness’s session store, so the harness resumes *the same agent* under a different brain.
5. **capture OUT**: read the harness’s native session after a run and extract the new context back into the canonical Portable Context.
6. **identity bind**: bind the session to a DID and seed-derived key (the agent’s owned self), without the seed ever leaving the client.
The steering layer may delegate to ACP where the harness is ACP-native, or to the native CLI where it is not. The portability layer is always PAC-specific: it touches the native store, which is exactly the bespoke-per-harness work PAC normalizes.
In this CLI: the launch verbs (`continue` / `start` / `run`) implement steering for the verified built-in adapters; `session export` implements capture OUT; `session materialize` implements materialize IN; the encrypted, hash-chained log plus the seat DID implement identity bind. Community (manifest) adapters implement the portability layer only.
## The architecture: canonical format + adapters
[Section titled “The architecture: canonical format + adapters”](#the-architecture-canonical-format--adapters)
PAC defines a canonical Portable Context (a normalized intermediate representation) and per-harness adapters that round-trip through it:
```plaintext
+------------------+ materialize +----------------+
| Portable | ---------------> | harness A |
| Context | (PAC->native) | native store |
| (canonical) | <--------------- | |
| | capture +----------------+
| encrypted, | (native->PAC)
| user-owned | +----------------+
| log | ---------------> | harness B |
+------------------+ materialize | native store |
+----------------+
```
One canonical format, N adapters, never N x N bilateral translators. Add a harness = write one adapter. The canonical format IS the portable log.
## The canonical Portable Context
[Section titled “The canonical Portable Context”](#the-canonical-portable-context)
The context is an **orientation pointer, not a state transplant**: it carries the task, concrete acceptance criteria, the plan with done/pending status, and a which-files-changed pointer. The receiving harness re-grounds in the actual repository with its own tools before acting. Exact runtime-state resumption is deliberately out of scope.
Fields (v1, JSON):
| Field | What it carries |
| -------------- | ------------------------------------------------------------ |
| `v` | payload schema version |
| `task` | the task in the user’s own words |
| `acceptance[]` | acceptance criteria (`text`, optional machine `check_cmd`) |
| `plan[]` | plan steps with `done` / `in_progress` / `pending` status |
| `files[]` | repo-relative paths touched so far (pointers, never content) |
| `repo` | re-grounding hints: HEAD commit, dirty flag |
| `provenance` | source harness id, source session id, export time, owner DID |
| `redaction` | visibility boundary (v1: `private`) |
Each context is stored as an encrypted, hash-chained event in the agent’s own log, so capture is verifiable and tamper-evident, and the log syncs operator-blind (the relay only ever sees ciphertext).
## Adapter levels
[Section titled “Adapter levels”](#adapter-levels)
* **Level 1 (declarative):** a JSONL-session harness is described by an `adapter.toml` manifest (session root, glob, JSON pointers, materialize line templates). A generic engine interprets it; no code is shipped. See [Building adapters](/docs/tally/adapters/building-adapters/) and the [adapter.toml reference](/docs/tally/adapters/adapter-toml-reference/).
* **Level 2 (native):** an exotic format gets a hand-written adapter implementing find / extract / materialize directly. The three in-tree adapters (claude-code, pi, codex) are the reference implementations.
## Conformance
[Section titled “Conformance”](#conformance)
A conforming PAC adapter must demonstrate:
1. **Round-trip integrity:** materialize a context, then capture it back; the task must be recovered verbatim and every declared field must survive or be explicitly declared as dropped. `tally adapter validate` runs this smoke for Level-1 manifests (materialize a marker context into a temp root, extract it back, assert recovery).
2. **Identity isolation:** the seed never appears in the Portable Context or the native store; only DID and key references.
3. **No silent state:** every dimension the harness’s native format carries is either captured into the canonical format or explicitly declared as dropped (Level 1 declares plan/files as honest-empty).
## What PAC is NOT
[Section titled “What PAC is NOT”](#what-pac-is-not)
* Not a harness launcher (commodity; rides the de-facto CLI contract).
* Not a runtime (PAC does not execute anything).
* Not a meta-harness (PAC is the layer beneath, not above).
* Not a memory SaaS (PAC is a portable format, not a hosted service).
* Not a task-handoff protocol (PAC carries state, not tasks).
PAC is exactly one thing: the owned, portable, round-trip state layer for AI agents.
## See also
[Section titled “See also”](#see-also)
* [Building adapters](/docs/tally/adapters/building-adapters/): the workflow guide (Level 1 and Level 2)
* [adapter.toml reference](/docs/tally/adapters/adapter-toml-reference/): the Level-1 manifest schema
* [The adapter registry](/docs/tally/adapters/registry/): how community adapters are shared and found
# The adapter registry
> The community adapter registry format: how adapters are listed, searched, and installed, and what community adapters can and cannot do.
Community adapters are shared as git repos: publish your `tally-adapter-` directory anywhere clonable, and anyone installs it with `tally adapter install `. The community registry is the index over those repos; it is searchable via `tally adapter search` and launches with the beta. Writing an adapter is covered in [Building adapters](/docs/tally/adapters/building-adapters/); the manifest schema is in the [adapter.toml reference](/docs/tally/adapters/adapter-toml-reference/).
Caution
**What community adapters can and cannot do.** Installed community adapters are marked “community, unverified” and reach the **session verbs only**: `tally session export --harness ` and `tally session materialize --into `. They **cannot** be used with the launch verbs (`tally continue` / `tally start` / `tally run`), which stay **built-ins-only for beta** (claude-code, pi, codex), because the launch verbs’ harness+model combo validation cannot cover unknown harnesses.
## The registry format
[Section titled “The registry format”](#the-registry-format)
```json
{
"v": 1,
"adapters": [
{
"name": "aider",
"harness": "aider",
"git": "https://example.org/alice/tally-adapter-aider",
"author": "alice",
"pac_conformance": "validate-pass"
},
{
"name": "goose-lite",
"harness": "goose",
"git": "https://example.org/bob/tally-adapter-goose-lite",
"author": "bob",
"pac_conformance": ""
}
]
}
```
### Fields
[Section titled “Fields”](#fields)
| Field | Meaning |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `v` | Registry schema version (currently `1`). |
| `adapters[]` | The list of registry entries. |
| `adapters[].name` | The adapter’s name (`[a-z0-9-]+`, matching the manifest’s `[adapter].name`). This is the name you pass to the session verbs after installing. |
| `adapters[].harness` | The harness the adapter targets. |
| `adapters[].git` | The git clone URL of the adapter repo; pass it to `tally adapter install`. |
| `adapters[].author` | Who published it. |
| `adapters[].pac_conformance` | The PAC conformance state: `"validate-pass"` when the adapter passes the `tally adapter validate` round-trip smoke; empty when no conformance result is recorded. |
## Searching and installing
[Section titled “Searching and installing”](#searching-and-installing)
`tally adapter search ` matches the term as a substring against `name`, `harness`, and `author`:
```sh
$ tally adapter search aider
```
Install from the entry’s git URL (or a local path):
```sh
$ tally adapter install https://example.org/alice/tally-adapter-aider
```
The manifest is parsed and validated before anything is written, and `install` refuses manifests containing shell-exec patterns unless you pass `--allow-shell` after reviewing the manifest (the pattern list is in the [adapter.toml reference](/docs/tally/adapters/adapter-toml-reference/#the-shell-pattern-scan)). Installed adapters land under `/adapters/`; `tally adapter list` shows built-ins (verified) alongside installed community manifests, and `tally adapter remove ` uninstalls a community adapter (built-ins refuse).
## See also
[Section titled “See also”](#see-also)
* [Building adapters](/docs/tally/adapters/building-adapters/): the create → validate → install → publish workflow
* [adapter.toml reference](/docs/tally/adapters/adapter-toml-reference/): the manifest schema
* [The PAC specification](/docs/tally/adapters/pac-spec/): the contract every adapter implements
# Beta FAQ
> What the Tally beta is, what a seat is, the caps, what is free, and what is not live yet.
Straight answers about the beta. If something here contradicts your experience, the docs are wrong and we want to know.
## What is the beta?
[Section titled “What is the beta?”](#what-is-the-beta)
A free, seat-capped run of the hosted Tally service: the CLI, the local encrypted agent log, launches under the built-in harnesses, and structurally end-to-end encrypted sync over the Skytale relay. The point of the beta is to learn what people actually use, with real limits stated up front.
## What is a seat?
[Section titled “What is a seat?”](#what-is-a-seat)
One account. When you sign up, you occupy one seat. Everyone is capped equally: an invited coworker creates a full account of their own, which counts against the same cap like anyone else’s. There are no guest seats and no reserved invites.
## How many seats are there, and how many agents per seat?
[Section titled “How many seats are there, and how many agents per seat?”](#how-many-seats-are-there-and-how-many-agents-per-seat)
The beta policy is **100 seats**, with up to **3 agents per seat** on the free tier (enforced as a device cap when an agent connects). If demand outruns the cap, raising it is a decision we can take, but the published policy is 100 and 3.
## What does the beta cost?
[Section titled “What does the beta cost?”](#what-does-the-beta-cost)
Nothing. Everything in the beta is free. A paid team tier is coming soon at ten dollars per user per month; it is announced, not purchasable yet. Beta members lock the beta price for life when it opens. Nothing in the beta requires payment, and the free tier stays free after the beta.
## What happens when the seats run out?
[Section titled “What happens when the seats run out?”](#what-happens-when-the-seats-run-out)
Signup tells you the beta is full and adds your email to the waitlist automatically. If a coworker invites you after the beta fills, you are waitlisted like anyone else before you can join; existing accounts keep working and are never waitlisted.
## What is not live yet?
[Section titled “What is not live yet?”](#what-is-not-live-yet)
Honesty is house law here, so plainly:
* **macOS and ARM builds.** The installer serves a Linux x86\_64 (glibc) binary; that is the only target built today. See [the honesty ledger](/docs/shared/honesty-ledger/) for the full target matrix.
* **Durable cloud saves.** `tally push` and `tally pull` move ciphertext between your own devices over the relay, and the relay retains it hosted-side only as a bounded catch-up buffer (a 7-day purge window plus a per-channel entry cap). A durable hosted save tier is not live. Your local log is the source of truth.
* **Paid checkout.** The paid team tier is announced (ten dollars per user per month, beta price locked for life for beta members) but cannot be purchased yet.
* **Community adapter launches.** Community adapters reach the session verbs (`tally session export` / `tally session materialize`) but cannot launch harnesses. The launch verbs are built-ins only (claude-code, pi, codex) for the beta.
## I installed something with cargo and `tally` does the wrong thing
[Section titled “I installed something with cargo and tally does the wrong thing”](#i-installed-something-with-cargo-and-tally-does-the-wrong-thing)
There is an unrelated crate on crates.io (named `tally-cli`) whose binary is also named `tally`. If you have it installed through cargo, the `tally` on your PATH may not be this Tally. Check with:
```sh
$ which tally
$ tally --help | head -1
```
The first line of this project’s `tally --help` names it `tally-coding-cli`. The recommended install path (the one-line installer on [the install page](/docs/tally/install/)) sidesteps cargo entirely.
Note
`tally doctor` is the fastest health check after any install: it verifies identity, home, harness installs, the chat runtime, relay/API reachability, and clock skew, and prints an actionable fix for every failing check.
## Where do I report problems?
[Section titled “Where do I report problems?”](#where-do-i-report-problems)
* **Bugs and rough edges:** open an issue on the public source repository’s tracker on GitHub, and include relevant `tally doctor` output; `tally whoami` and `doctor` never print secrets.
* **Security issues:** please do not open a public issue. See [reporting vulnerabilities](/docs/shared/reporting-vulnerabilities/).
## Where do I start?
[Section titled “Where do I start?”](#where-do-i-start)
[The quickstart](/docs/tally/quickstart/), then [backup and recovery](/docs/tally/guides/backup-and-recovery/) before you build anything you care about on your agent’s identity.
# Harnesses and models
> The agent is durable; the harness and model are choices you make per launch. Three built-in harnesses, two flag surfaces, and validation before anything is written.
The agent is the durable noun; the harness and model are adjectives you pick at launch time. `tally continue vision --claude-code --fable-5` today, `tally continue vision --codex --gpt-5.5` tomorrow: same identity, same memory, same history, different equipment. This page explains the harness and model axes and how Tally validates the combination you asked for.
## The three v1 harnesses
[Section titled “The three v1 harnesses”](#the-three-v1-harnesses)
Launch supports three harnesses, compiled in and verified:
* **`claude-code`**: runs Anthropic models.
* **`pi`**: multi-provider, so the model axis is widest here.
* **`codex`**: runs OpenAI models.
All three adapters are built-in Rust. Other harnesses can be added with a declarative adapter (a TOML manifest describing the harness’s session store), managed via `tally adapter`.
Note
Community adapters reach the session verbs (`tally session export --harness ` / `tally session materialize --into `) but not the launch verbs. Launch is built-ins-only for the beta. See [Building adapters](/docs/tally/adapters/building-adapters/).
## Two flag surfaces, one meaning
[Section titled “Two flag surfaces, one meaning”](#two-flag-surfaces-one-meaning)
Every launch verb accepts the harness and model in a canonical form and a sugar form. They are equivalent; use whichever reads better to you:
```sh
# canonical
$ tally continue vision --harness pi --model gpt-5.5
# sugar: harness shortcut + model alias
$ tally continue vision --pi --gpt-5.5
$ tally continue vision --claude-code --fable-5
$ tally continue vision --codex --gpt-5.5
```
The sugar flags are shortcuts, not a separate system; `--claude-code` means `--harness claude-code` and nothing more.
## How combos are validated
[Section titled “How combos are validated”](#how-combos-are-validated)
Three tiers, from strongest to weakest guarantee:
* **Marketed, known model shortcuts are guaranteed to launch.** They are validated against the installed harness; `--fable-5` resolves to `claude-fable-5`, for example. These are the combos shown in these docs, and only these.
* **Other model tokens are family-validated.** Tally checks the token belongs to a plausible family for the harness, and the harness itself makes the final model check at launch.
* **Clearly invalid pairs are rejected before anything is written.** An OpenAI model on claude-code fails immediately: no context is materialized, no session store is touched, nothing to clean up.
## Seeing the command without running it
[Section titled “Seeing the command without running it”](#seeing-the-command-without-running-it)
Every launch verb takes `--print-cmd`, which prints the composed harness command instead of launching it. Use it to check what Tally is about to run, or to wire the command into something else:
```sh
$ tally continue vision --pi --gpt-5.5 --print-cmd
```
## The honest caveat: continuity is not capability
[Section titled “The honest caveat: continuity is not capability”](#the-honest-caveat-continuity-is-not-capability)
The swap preserves *who* the agent is: identity, memory, history, character. It does not make capabilities uniform. A log built under a strong model, continued under a weaker one, degrades; harnesses differ in the tools they expose. Portability is of the agent, not its powers, and a harness or model swap is never free. We say this here so no launch flag has to imply otherwise.
## Where next
[Section titled “Where next”](#where-next)
* [Continue, start, and run](/docs/tally/guides/continue-start-run/) : the three verbs these flags attach to
* [Building adapters](/docs/tally/adapters/building-adapters/) : add a harness at the session layer
* [tally continue](/docs/tally/reference/cli/continue/) : the full flag reference
# Memory and character
> The two documents that make an agent a self: curated memory the agent accumulates, and standing character you write. Both encrypted, both injected at every launch.
An agent in Tally is more than a session transcript. Its home holds four things: an identity (who it is), a character (how it behaves), a memory (what it knows), and a history (what happened, as the encrypted event log). This page covers the two you interact with day to day: **memory** and **character**.
Both are per-agent documents. Both live inside the agent’s encrypted log. Both are injected into every launch, so the agent that comes up under `tally continue`, `tally start`, or `tally run` already knows what it knows and behaves how you told it to, regardless of which harness or model you picked this time.
## Memory: what the agent knows
[Section titled “Memory: what the agent knows”](#memory-what-the-agent-knows)
Memory is the agent’s curated knowledge document. It is:
* **Owner-readable and owner-editable.** It is a text document you can print, audit, correct, or redact at any time. It is not an opaque server-side embedding store.
* **Encrypted in the log.** Memory is stored in the agent’s encrypted event log, so it gets everything the log already has: encryption at rest, sync, and recovery alongside the rest of that agent’s history.
* **Hash-chained provenance.** Every saved version of the memory is a new hash-chained entry in the log. “Why does my agent believe X” has an inspectable answer instead of a shrug.
### How memory grows: the reflect pass
[Section titled “How memory grows: the reflect pass”](#how-memory-grows-the-reflect-pass)
You do not have to maintain the memory by hand (though you can). After each captured session, a background **reflect** pass distills durable knowledge from that session into the memory document. Reflection runs as a one-shot on **your own harness and model**: Tally is BYO-brain, so distillation uses the brain you already pay for, not a model we host.
Reflection is controlled in three places:
* **Config defaults:** `auto_reflect` and `reflect_model` in `tally config` set whether reflection runs and which model it uses. The `reflect_model` config key overrides the model passed on the command line.
* **Per launch:** pass `--no-reflect` to `continue`, `start`, or `run` to skip the reflect pass for that session.
* **On demand:** run the pass yourself with `tally memory reflect`.
```bash
# distill pending captured sessions into memory, explicitly
$ tally memory reflect --harness claude-code --model vision
# set-and-forget defaults instead
$ tally config set auto_reflect true
$ tally config set reflect_model
```
`tally memory reflect` requires `--harness` (one of `claude-code`, `pi`, `codex`) and `--model`. The launch verbs run this same pass in the background after capture.
Note
Capture is deterministic and primary; reflection is best-effort distillation on top. A skipped reflection loses nothing, because the captured session is already in the log and a later reflect pass can pick up the pending sessions.
### Viewing and editing memory
[Section titled “Viewing and editing memory”](#viewing-and-editing-memory)
```bash
# print the agent's current memory document
$ tally memory show vision
# open it in $VISUAL/$EDITOR; saving creates a new hash-chained version
$ tally memory edit vision
```
Both commands take an optional agent name. Omit it and they operate on the single-seat home (the default agent on a one-agent machine).
Editing is a first-class path, not a workaround. If the agent learned something wrong, you fix the document, and the correction is itself a new hash-chained version in the log.
Full command reference: [tally memory](/docs/tally/reference/cli/memory/).
## Character: how the agent behaves
[Section titled “Character: how the agent behaves”](#character-how-the-agent-behaves)
Character is the agent’s standing instructions and voice: the layer that makes an agent *yours*. Things like “you verify before claiming done,” “prefer small diffs,” or a role definition live here.
Character differs from memory in one important way: **you write it; the agent does not**. There is no reflect pass for character. It changes only when you edit it.
```bash
# print the agent's current character document
$ tally character show vision
# open it in $VISUAL/$EDITOR; saving creates a new version
$ tally character edit vision
```
Like memory, character is stored in the agent’s encrypted log, versioned on every save, and synced and recovered with everything else.
Full command reference: [tally character](/docs/tally/reference/cli/character/).
## Memory vs character, in one table
[Section titled “Memory vs character, in one table”](#memory-vs-character-in-one-table)
| | Memory | Character |
| ------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------- |
| What it is | What the agent knows (project facts, learned preferences, self-knowledge) | How the agent behaves (standing instructions, voice, role) |
| Who writes it | The reflect pass, plus you (`memory edit`) | You (`character edit`) |
| How it changes | After captured sessions, via reflection on your own harness | Only when you edit it |
| Versioning | New hash-chained version per save | New version per save |
| Storage | Encrypted in the agent’s log | Encrypted in the agent’s log |
| Injected at launch | Yes, every launch | Yes, every launch |
## When injection happens
[Section titled “When injection happens”](#when-injection-happens)
Every launch verb injects character and memory automatically:
* **`tally continue `** injects them alongside the agent’s newest portable context.
* **`tally run `** injects them before the one-shot task.
* **`tally start `** injects them too. A fresh `start` is a fresh *session*, not a fresh *self*: the new session begins with the agent’s character and memory already in place. Starting an agent means “same self, blank conversation.”
This is what makes the harness and model swappable. The self travels in the encrypted home; the harness is just where it runs today.
Tip
Character is agent-scoped, not repo-scoped. Repo-level instruction files (CLAUDE.md, AGENTS.md) belong to the repo and its git history. Put “how this agent behaves everywhere” in character; leave “how to work in this repo” in the repo.
Caution
Portability preserves who the agent is, not what it can do. A memory built while working under a strong model does not make a weaker model stronger, and harnesses differ in available tools. Expect the same self, not identical capability, across swaps.
## Verifying the record
[Section titled “Verifying the record”](#verifying-the-record)
Because memory and character live in the hash-chained log, their integrity is checkable, not assumed:
```bash
# print the event count and whether the hash chain verifies
$ tally status
# inspect the local encrypted event log directly
$ tally log --help
```
If someone (or something) tampered with the stored history behind your back, the chain does not verify. This is the same property that makes the provenance claim honest: a memory version points back through the chain to the session that produced it.
## Sync and recovery
[Section titled “Sync and recovery”](#sync-and-recovery)
Memory and character need no special handling within an agent. They are entries in that agent’s log, so they travel with everything else in it: pushed (sealed, as ciphertext) to the agent’s channel, pulled and hash-chain-verified on another device, and readable again once the agent’s identity is recovered.
The unit of sync and recovery is **the agent**, and there are two per-agent facts to get right:
* **Each agent is its own seed, its own log, and its own recovery story.** A named agent (`tally start vision ...`) has its own independent seed and its own home under `/agents//`; the base seat is a separate seat with a separate seed. Back up each agent you care about with `tally backup `. Backing up only the base seat does not protect your named agents: their seeds are not derived from it, and recovering the base phrase alone does not bring them back.
* **A seed restores identity and keys, not content.** Recovering an agent’s phrase on a new machine re-derives who the agent is. What it knows comes back via `tally pull` (within the relay’s bounded catch-up window) or by copying the agent’s encrypted log from a machine that still holds it.
There is no separate memory backup to manage and no separate character store to migrate; back up the agent and its memory and character come with it. See [Multi-device sync](/docs/tally/guides/multi-device-sync/) for the per-agent recipe and [What syncs, and what the cloud can see](/docs/tally/concepts/what-syncs/) for exactly what leaves the machine.
## Quick reference
[Section titled “Quick reference”](#quick-reference)
```bash
$ tally memory show [AGENT] # print the current memory document
$ tally memory edit [AGENT] # edit in $VISUAL/$EDITOR; new hash-chained version
$ tally memory reflect \
--harness --model [AGENT] # distill pending captured sessions into memory
$ tally character show [AGENT] # print the current character document
$ tally character edit [AGENT] # edit in $VISUAL/$EDITOR; new version
$ tally config set auto_reflect true # reflect automatically after capture
$ tally config set reflect_model # which model the reflect one-shot uses
```
In every case, `[AGENT]` defaults to the single-seat home when omitted.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* Command reference: [tally memory](/docs/tally/reference/cli/memory/) and [tally character](/docs/tally/reference/cli/character/)
* The launch verbs that inject both: [Continue, start, and run](/docs/tally/guides/continue-start-run/)
* What leaves the machine when this syncs: [What syncs, and what the cloud can see](/docs/tally/concepts/what-syncs/)
# The agent home
> The agent is the durable noun: a seed-derived identity plus an encrypted log, kept in a seat home on your machine, launchable under any harness.
In Tally, the durable thing is not a session in some vendor’s tool. It is the **agent**: an identity plus an encrypted log, kept in a home directory you hold. Harnesses and models come and go per launch; the agent persists. This page explains what that home contains and where it lives.
## A seat is an identity plus a log
[Section titled “A seat is an identity plus a log”](#a-seat-is-an-identity-plus-a-log)
A **seat** is an installed agent identity: a DID derived from a seed, with its own home directory and its own encrypted event log. Everything the agent is lives in that pair:
* **The identity** answers “who is this agent”. The DID is derived deterministically from a seed, so the same seed re-derives the same identity on any machine. The seed is recoverable from a 24-word BIP-39 phrase.
* **The log** holds everything else: the agent’s conversation history, its memory, its character, all encrypted at rest under keys derived from that seed. See [The portable log](/docs/tally/concepts/the-portable-log/).
Secrets live in `identity.json` and `relay.json` inside the tally home, and nowhere else. The config store refuses secret-looking keys by design, so a seed or token cannot end up in `tally config`.
## The base seat and named agents
[Section titled “The base seat and named agents”](#the-base-seat-and-named-agents)
Every install has a **base seat**: the default identity that commands operate on when you name no agent. Named agents each get their own seat home under `/agents//`, with their own identity and their own log. `vision` in the examples across these docs is a named agent; its whole self lives in `agents/vision/`.
The `tally agent` command manages these homes: list them with last activity, rename one, or remove one. Removal permanently deletes a seat’s identity and encrypted log, so it is confirm-gated and refuses the base seat.
```sh
# inspect a seat: DID, agent home, relay/channel; prints no secrets
$ tally whoami vision
```
## Identity is created silently, revealed deliberately
[Section titled “Identity is created silently, revealed deliberately”](#identity-is-created-silently-revealed-deliberately)
`tally init` generates a fresh identity **silently**: no mnemonic scrolls past in your terminal, one copy of the seed goes to the OS keyring when available. This is deliberate. The reveal is a ceremony you choose, not a wall of words at birth:
* `tally backup` prints the 24-word recovery phrase and confirms you actually stored it by asking you to re-type two randomly chosen words. Success marks the seat backed up; `tally backup --status` shows the backed-up state per agent.
* `tally signin` is the lower-level path: its first call generates the identity and prints the phrase directly; `--recover` re-derives an identity from an existing phrase on a new machine.
The consequence is wallet-shaped and worth stating plainly: the seed is the agent. Lose the seed with no backup and the encrypted log is unreadable, by design. See [Backup and recovery](/docs/tally/guides/backup-and-recovery/).
## The four parts of a self
[Section titled “The four parts of a self”](#the-four-parts-of-a-self)
The home holds four things, and the split is visible in the CLI itself:
| Part | What it answers | Where you see it |
| --------- | ---------------- | ------------------------------------------------------------ |
| Identity | who the agent is | `tally whoami` (the DID) |
| History | what happened | the encrypted event log; `tally status`, `tally log` |
| Memory | what it knows | `tally memory show` / `edit`; owner-readable, owner-editable |
| Character | how it behaves | `tally character show` / `edit`; injected at every launch |
Memory and character are per-agent documents stored as hash-chained entries in the same encrypted log, which is why they sync, back up, and recover exactly like the history does. They are covered in depth in [Memory and character](/docs/tally/concepts/memory-and-character/).
## What the home is not
[Section titled “What the home is not”](#what-the-home-is-not)
The agent home is not a copy of any harness’s session store. Harness stores (Claude Code’s, pi’s, codex’s) are launch targets: at `tally continue`, the agent’s latest portable context is materialized *into* the chosen harness’s native store, and on exit the session is captured *back* into the home’s log. The home stays the source of truth; the harness store is scratch space the agent visits.
That is the whole design in one sentence: one encrypted home you hold the key to, visited by whatever harness and model you picked today.
## Where next
[Section titled “Where next”](#where-next)
* [Memory and character](/docs/tally/concepts/memory-and-character/) : the two documents you interact with day to day
* [The portable log](/docs/tally/concepts/the-portable-log/) : how the history is stored, chained, and verified
* [Backup and recovery](/docs/tally/guides/backup-and-recovery/) : the seed ceremony and what loss means
# The portable log
> Every agent keeps an encrypted, hash-chained event log on your machine. Sessions are captured into it, contexts materialize out of it, and the chain proves nothing was tampered with.
Every seat keeps a local **encrypted, hash-chained event log** (`tally.db`). It is the agent’s history: what happened, in order, sealed at rest under keys derived from your seed, with each event cryptographically linked to its parent. This page explains what goes into the log, what comes out of it, and how you check it.
## Capture: sessions go in
[Section titled “Capture: sessions go in”](#capture-sessions-go-in)
The log is harness-agnostic by construction. When a launch verb’s harness exits, Tally captures the session the harness wrote back into the encrypted log as the agent’s next **portable context**. The capture happens at Tally’s layer, in Tally’s canonical format, not by copying the harness’s native files.
This one design choice is why everything downstream works the same everywhere. Sync does not need to understand Claude Code’s store, pi’s store, or codex’s store; it moves entries of one log format. A session run under any supported harness lands in the same log, chains onto the same history, and syncs through the same pipe. See [What syncs, and what the cloud can see](/docs/tally/concepts/what-syncs/).
## The portable context: sessions come out
[Section titled “The portable context: sessions come out”](#the-portable-context-sessions-come-out)
On top of the raw events sits the **portable context**: a canonical export of a coding session holding the task, the acceptance criteria, the plan, and a files pointer. It is the unit of continuation. `tally session export` extracts a harness session into one; `tally session materialize` turns a stored one back into any supported harness’s native session store so that harness resumes it.
That pair is what `tally continue` composes: resolve the seat, load the newest context, materialize it into the chosen harness, launch. The context is deliberately a distilled working state, not a byte-copy of a transcript, which is what lets it cross harness boundaries at all.
`tally session show` prints the digest (or raw JSON) of any stored context, and `tally session verify` checks the repo against the context’s acceptance criteria, independent of what the harness claimed about its own work.
## Branching: strictly, a tree
[Section titled “Branching: strictly, a tree”](#branching-strictly-a-tree)
Strictly, the log is a **hash tree**, not only a chain. Each event seals its parent’s hash. Continue the same stored context twice (say, once under each of two models) and the two sessions become sibling branches from the same parent, each verifying independently. Tamper with any event and its whole subtree fails verification.
Nothing is overwritten to make this work; branches are what an append-only, parent-linked log gives you when two continuations share an ancestor.
## Verifying it yourself
[Section titled “Verifying it yourself”](#verifying-it-yourself)
Two commands answer “is my history intact” from the machine itself:
```sh
# walk the entire hash chain; exit 0 if intact, 1 on tamper or gap
$ tally log verify
# the event count, and whether the chain verifies
$ tally status
```
`tally log verify` is the strict check with a usable exit code; `tally status` is the one-glance version. Neither asks a server anything, because the log and the proof of its integrity are both local.
## What the log is for, in one line
[Section titled “What the log is for, in one line”](#what-the-log-is-for-in-one-line)
Capture turns any harness’s session into the agent’s history; the portable context turns that history back into any harness’s next session; the hash tree proves the history you resume is the history you left.
## Where next
[Section titled “Where next”](#where-next)
* [What syncs, and what the cloud can see](/docs/tally/concepts/what-syncs/) : how the log moves between your machines as ciphertext
* [Continue, start, and run](/docs/tally/guides/continue-start-run/) : the verbs that capture into and materialize out of the log
* [tally session](/docs/tally/reference/cli/session/) : export, materialize, show, and verify, in full
# What syncs, and what the cloud can see
> Exactly what leaves your machine, in what form, and what the relay can and cannot read. Sealed log deltas out, ciphertext through, plaintext only on your devices.
Short version: the harness runs the agent on your machine; the relay syncs the agent’s sealed memory as ciphertext; plaintext lives only on your devices. This page spells out each part precisely.
## What syncs
[Section titled “What syncs”](#what-syncs)
The thing that syncs is the **portable encrypted agent log**: a harness-agnostic event log of the agent’s memory, conversation history, and identity. It is not a copy of any harness’s native session store; Tally captures sessions *into* this format at its own layer, which is why sync works the same no matter which harness ran the agent.
The sync unit is a **sealed log delta**, not a file copy. `tally push` batches every log entry appended since the last push, seals the batch, and publishes the ciphertext.
## In what form: sealed deltas
[Section titled “In what form: sealed deltas”](#in-what-form-sealed-deltas)
From `tally push`:
* Each batch is sealed with your **seed-derived sync key** (AES-256-GCM) before it leaves the machine.
* What goes over the wire, and what the relay stores, is **ciphertext only**.
From `tally pull` on another device:
* The device back-fills the seat channel from its saved cursor.
* Each batch is decrypted locally with the same seed-derived sync key.
* Entries are applied to the local log idempotently, and the hash chain is re-verified after apply.
```bash
# device A: publish new local-log deltas to your seat's channel
$ tally push
# device B: pull the deltas and apply them locally
$ tally pull
```
Both commands require a configured relay. The relay can also be configured via the `TALLY_RELAY_URL` and `TALLY_RELAY_JWT` environment variables; `tally push` tells you what to do if the relay is unset.
## Provisioning a seat: tally connect
[Section titled “Provisioning a seat: tally connect”](#provisioning-a-seat-tally-connect)
`tally connect` is the once-per-seat setup that makes push and pull work. It registers the channel, runs the device-grant, and stores the resulting **channel-scoped credential** to `/relay.json`.
```bash
# owner key from $SKYTALE_API_KEY (or pass --api-key)
$ tally connect
```
Options worth knowing:
* `--api-key `: the owner API key; otherwise read from `$SKYTALE_API_KEY`.
* `--channel `: override the channel; the default is the seat’s `pronoic/agents/`.
* `--api-url `: API base URL; defaults to `$SKYTALE_API_URL` or `https://api.skytale.sh`.
* `--relay-url `: the per-owner relay URL. The CLI syncs over WsTransport (`/ws/` routing), so pass a relay that speaks that, not the gRPC endpoint.
* `--seat