Build it into an app
The CLI proved the round trip. The SDK is the same thing from your own code: connect to your tenant, sign a user in, find another, send. One surface, four languages. Below is what exists today, including the parts that are not ready.
What this takes today: every head builds from the public repository (there is no published package yet), it is one-to-one only, and it is not yet independently audited. The full list is what is not ready, below.
Pick a language
TypeScript
Browsers and Node. The same Rust client the native heads run, compiled to WebAssembly, reaching the service over WebSockets so a page needs nothing but HTTPS. Built from the repository; not on npm yet.
Swift
A Swift Package wrapping an xcframework, for macOS and iOS. Build it
from the repository with build-xcframework.sh and add the
package.
Kotlin
An .aar carrying the four Android ABIs. Build it with
build-aar.sh and declare it as a dependency.
Rust
The client crate the CLI itself uses, and the surface every other head is generated from. The most direct path if your service is already Rust.
The shape of it
Two objects. A tenant handle is built from your API key
and the server's name; it fetches the server's service document once to
learn where the services are, so no app carries a host or a port, and a
self-hosted deployment is just a different name. It signs users up and
in. A client is one signed-in user on one device: it
finds other users, sends, receives (as a batch with receive,
or one message at a time as they arrive with inbound, the
language's own stream), and exports its state so the same identity comes
back next run. Sessions establish themselves on first
contact, and the client reconnects and drains anything queued while it was
away.
const tenant = await Tacenta.connect("tct_your_api_key");
const alice = await tenant.signIn("alice", "correct horse");
const bob = await alice.find("bob");
if (bob) await alice.send(bob, "hello");
for await (const m of alice.inbound()) console.log(m.from, m.text());
TypeScript shown. Every head has the same two objects; in Swift and
Kotlin the handle is called Tenant, since the Swift module is
itself Tacenta. Each language page shows the sample in its
own spelling.
Which calls exist where
One row per call, one column per language, from the manifest every head's test suite checks itself against. A dash is a call that head does not have yet, with the reason beneath the table. This is the surface as of v1.12.1, the last release; it is generated, not maintained.
Tacenta
One tenant's handle: built from the API key and the server's name, it fetches the service document once and hands out signed-in clients.
| Call | Rust | TypeScript | Swift | Kotlin |
|---|---|---|---|---|
| connect | Tacenta::connect | Tacenta.connect | Tenant.connect | Tenant.connect |
| signUp | Tacenta::sign_up | Tacenta.signUp | Tenant.signUp | Tenant.signUp |
| signIn | Tacenta::sign_in | Tacenta.signIn | Tenant.signIn | Tenant.signIn |
| signInWithState | Tacenta::sign_in_with_state | Tacenta.signInWithState | Tenant.signInWithState | Tenant.signInWithState |
| signInWithStateSealed | Tacenta::sign_in_with_state_sealed | – | Tenant.signInWithStateSealed | Tenant.signInWithStateSealed |
| websocket | Tacenta::websocket | – | Tenant.websocket | Tenant.websocket |
- signInWithStateSealed: The TypeScript head has no sealed state yet.
- websocket: TypeScript always takes the carriage, so there is no switch.
Client
One signed-in user on one device.
| Call | Rust | TypeScript | Swift | Kotlin |
|---|---|---|---|---|
| address | Client::address | Client.address | Client.address | Client.address |
| find | Client::find | Client.find | Client.find | Client.find |
| send | Client::send | Client.send | Client.send | Client.send |
| receive | Client::receive | Client.receive | Client.receive | Client.receive |
| inbound | Client::inbound | Client.inbound | Client.inbound | Client.inbound |
| restoreOutcome | Client::restore_outcome | Client.restoreOutcome | Client.restoreOutcome | Client.restoreOutcome |
| exportState | Client::export_state | Client.exportState | Client.exportState | Client.exportState |
| exportStateSealed | Client::export_state_sealed | – | Client.exportStateSealed | Client.exportStateSealed |
| attachSecureStore | Client::attach_secure_store | – | Client.attachSecureStore | Client.attachSecureStore |
| reconnect | Client::reconnect | – | – | – |
- receive: Awaits the next non-empty batch on every head; inbound is the same one message at a time. A pending receive waits for mail outside the client's turn, so a send on the same client goes through meanwhile.
- inbound: Rust: a Stream that borrows the client; TypeScript: an async iterator for `for await`; Swift: an Inbound that is an AsyncSequence; Kotlin: an Inbound with `asFlow()`. A message goes to whichever loop is running, so run one per client.
- restoreOutcome: Only the sealed restore and a checkpoint can discard sessions, so on TypeScript, which has neither, it is fresh or resumed.
- exportState: The bytes carry private keys: app-private, encrypted at rest, only the latest copy kept, and exported again after every send and receive (a restore of an older copy rewinds sessions and, on the sealed path, is refused).
- exportStateSealed: No sealed state on the TypeScript head yet.
- attachSecureStore: No secure-store hook on the TypeScript head yet.
- reconnect: Every head reconnects on its own with backoff; only Rust exposes the call.
Inbound
A client's inbound messages one at a time, on the FFI heads; Rust's Stream and TypeScript's async iterator carry their own next.
| Call | Rust | TypeScript | Swift | Kotlin |
|---|---|---|---|---|
| next | – | – | Inbound.next | Inbound.next |
- next: Rust and TypeScript iterate the stream itself; the Swift and Kotlin sugar is built on this call.
Errors
Every call raises one error type with a kind to branch on, the same kinds on every head. The message is the detail.
| Kind | Rust | TypeScript | Swift | Kotlin |
|---|---|---|---|---|
| Network | ErrorKind::Network | "network" | ClientError.Network | ClientException.Network |
| Discovery | ErrorKind::Discovery | "discovery" | ClientError.Discovery | ClientException.Discovery |
| UnknownTenant | ErrorKind::UnknownTenant | "unknownTenant" | ClientError.UnknownTenant | ClientException.UnknownTenant |
| UsernameTaken | ErrorKind::UsernameTaken | "usernameTaken" | ClientError.UsernameTaken | ClientException.UsernameTaken |
| InvalidUsername | ErrorKind::InvalidUsername | "invalidUsername" | ClientError.InvalidUsername | ClientException.InvalidUsername |
| WeakPassword | ErrorKind::WeakPassword | "weakPassword" | ClientError.WeakPassword | ClientException.WeakPassword |
| SignUpRefused | ErrorKind::SignUpRefused | "signUpRefused" | ClientError.SignUpRefused | ClientException.SignUpRefused |
| SignInRefused | ErrorKind::SignInRefused | "signInRefused" | ClientError.SignInRefused | ClientException.SignInRefused |
| IdentityMismatch | ErrorKind::IdentityMismatch | "identityMismatch" | ClientError.IdentityMismatch | ClientException.IdentityMismatch |
| NotFound | ErrorKind::NotFound | "notFound" | ClientError.NotFound | ClientException.NotFound |
| RateLimited | ErrorKind::RateLimited | "rateLimited" | ClientError.RateLimited | ClientException.RateLimited |
| ServerFailure | ErrorKind::ServerFailure | "serverFailure" | ClientError.ServerFailure | ClientException.ServerFailure |
| State | ErrorKind::State | "state" | ClientError.State | ClientException.State |
| StoreUnavailable | ErrorKind::StoreUnavailable | "storeUnavailable" | ClientError.StoreUnavailable | ClientException.StoreUnavailable |
| InvalidArgument | ErrorKind::InvalidArgument | "invalidArgument" | ClientError.InvalidArgument | ClientException.InvalidArgument |
| Internal | ErrorKind::Internal | "internal" | ClientError.Internal | ClientException.Internal |
- Network: The network or the transport failed; retry later.
- Discovery: The service document could not be fetched or read.
- UnknownTenant: The API key selects no tenant.
- UsernameTaken: The username is already taken in this tenant.
- InvalidUsername: The username is not one the server accepts.
- WeakPassword: The password is too weak.
- SignUpRefused: A sign-up was refused for another reason: registration is closed, or the handle is reserved.
- SignInRefused: The credentials were refused, or the session expired; coarse by design.
- IdentityMismatch: The address is bound to a different device identity (trust on first use); also a sealed state offered to a user or device it was not sealed for.
- NotFound: The address is not registered.
- RateLimited: The server asked for a slower pace: too many failed sign-ins, or the recipient's queue is full. Back off and retry.
- ServerFailure: The server could not process the request; nothing was applied. Retry.
- State: The persisted state was refused: altered, older than the last send, or its secure-storage key is wrong; whatever else a SecureStore raises surfaces here. Do not delete the blob.
- StoreUnavailable: The platform's secure store could not be reached (a Keychain before first unlock, a Keystore that needs the user). Nothing was refused: retry after unlock and keep the blob.
- InvalidArgument: The caller's own input was wrong: a malformed address or config, a message over the size limit.
- Internal: A protocol or cryptographic failure, or a bug: worth reporting.
The same grid, as the repository renders it, is at tacenta.com/dl/sdk/SURFACE.md, and the manifest itself at surface.json.
Manage your keys
The key from signup is the first one. Mint more, list what exists, or revoke one that leaked, with the CLI:
tacenta keys create --label ci
tacenta keys list
tacenta keys revoke tct_a1b2c3
Each command prompts for the email and password you set at signup, rather
than reading a saved API key: it manages the tenant's actual keys, a
different, tenant-admin credential from the API key context
and try use, and there is no tenant session yet for it to
hold instead. Nothing is written to disk. Keep that password: it is the
only credential that manages keys, and there is no reset flow yet.
What is not ready
Because finding out later wastes your time:
- No published package yet
- Every head builds from the repository. There is no npm package, no package registry entry and no hosted binary framework, so adding Tacenta means building the artifact yourself for now.
- Not yet independently audited
- No independent external audit has issued a report. What is proven, what is tested and what is assumed is on the assurance page, and nothing here claims more than it does.
- No password reset
- Key management authenticates with the email and password from signup, and there is no reset flow yet. Lose the tenant password and recovery needs manual support, so store it where you keep other infrastructure secrets. There is also no web dashboard: rotation and revocation are CLI only.
- Group messaging is not implemented
- One to one only. Group messaging needs sender keys, which are specified but not built.
- Delivery is to connected devices
- There is no mobile push integration yet, so a device that is not connected collects its messages when it next connects rather than being woken.
Tell us what broke
The most useful thing you can send is the point where you stopped. Not a bug report, just the step: the command that failed, the doc that said the wrong thing, or the moment you could not tell whether something had worked. Mail info@natuvea.com with the step you got to. Things that worked first time are worth hearing about too, since they tell us what not to change.