# Changelog
Source: https://docs.terminal3.io/developers/adk/changelog
What's changed in these docs, and what we know about SDK version history so far.
This page currently tracks **documentation** changes we can verify directly, since we don't yet have a confirmed, complete SDK release history to publish. If you need details on a specific past SDK release, ask in the [developer Telegram](https://t.me/terminal3developer) — we'll backfill verified entries here as we confirm them.
## Documentation
### 2026-09-21
* Confirmed against the `testnet-v1.0.11` release (current SDK `5.19.0`): updated [Member Delegation](/developers/adk/get-started/member-delegation) for the breaking grant-shape change below — the "What a grant can say" table and both code samples now show one function per grant row.
### 2026-09-18
* Confirmed against the `testnet-v1.0.10` release (current SDK `5.16.0`): documented the full agent lifecycle — `createAgent` (now with an `initialCredits` option), `fundAgent`, `deleteAgent`, `updateAgent`, `deregisterAgent` — in the SDK section below, [Register an Organization-owned Agent](/developers/agents/provision-org-agent), and [Register a Public Agent](/developers/agents/register-agent).
* Added a copy-paste AI-coding-assistant prompt pair (Claude-flavored and OpenAI-flavored) for self-service agent credit funding — see [Using AI Coding Assistants](/developers/adk/support/ai-coding-assistants).
* Added `resolveTrustAnchor()` and the new required `rtmr1_allowlist` field to [Verify the trust anchor](/developers/adk/tips/verify-trust-anchor) — RTMR1, not RTMR3, is now the meaningful rootfs-integrity measurement.
* Documented the organisation usage-report / call-count methods (`getOrgUsageReport`, `getOrgWorkflowExecutionsForWindow`, `getOrgActivityCountForWindow`), `getDelegation`'s new `grantee_kinds`, `countAgentsByContract`/`countAllAgentsByContract`, and `setDefaultQuotas`/`updateQuotas` in the [SDK & API Reference](/developers/adk/reference).
* Marked the `ap2` (`ap2/mandate`) host interface **removed** in [Host API](/t3n/how-t3n-works/host-api) and the reference page — the interface and its linker world were deleted from the runtime in this release.
* Updated [Common Errors](/developers/adk/tips/common-errors) for the new JSON error envelope on all 4xx/5xx responses (not just contract-level `bad_request`s), the rewritten egress-denied message, and the `agent-credit-transfer` error table.
### 2026-09-08
* Renamed "Agent Auth" to [Member Delegation](/developers/adk/get-started/member-delegation) throughout — the underlying vocabulary shift (`agent_auth`→`member_delegation`, `script`→`contract`) is covered in the SDK section below — and moved the page from `overview/` to `get-started/` to match its nav group and URL slug.
* Documented the audit-log CSV export methods (see the SDK section below) and added the [Read your organization's activity log](/developers/adk/tips/get-activity-log) and [Running the code samples in this guide](/developers/adk/tips/running-code-samples) tip pages, plus [Verify the trust anchor](/developers/adk/tips/verify-trust-anchor) and a stateless `invoke()` guide on the Member Delegation page.
* [SDK & API Reference](/developers/adk/reference): added `setEnvironment`'s `sandbox` value, split `tenant.contracts.execute()` from `client.executeAndDecode()` (they're on different objects), filled in `tenant.maps`' `delete`/`entrySet`/`entryGet`/`getStatus` methods, and corrected the `outbox`/`ap2` WIT host interfaces from "coming soon" to "Available" (both are live, CI-tested capabilities).
* Fixed several stale references caught while re-verifying every page against the confirmed `testnet-v1.0.9` release: a duplicated code sample in [Quickstart](/developers/adk/get-started/quickstart), outdated host-interface version numbers in [Write your first TEE contract](/developers/adk/get-started/walkthrough/write-contract), and a broken link left over from the Member Delegation page move.
### 2026-07-06
* Added [Quickstart](/developers/adk/get-started/quickstart) — a single page to get an authenticated call working before touching contract code.
* Filled in the previously-empty [ADK Tour](/developers/adk/overview/adk-tour) and [Member Delegation](/developers/adk/get-started/member-delegation) pages.
* Added this [Changelog](/developers/adk/changelog) and the [SDK & API Reference](/developers/adk/reference) page.
* Fixed a duplicate "Step 4" between the Invoke and Test walkthrough pages.
* Expanded [Common Errors](/developers/adk/tips/common-errors) with generic-HTTP-500 triage guidance and a table of common integration gotchas (tenant DID double-encoding, `baseUrl` handling).
* Added a warning about contract version-shadowing on re-registration to [Register your TEE contract](/developers/adk/get-started/walkthrough/register-contract).
## SDK
### Delegation grants: one function per grant, unified `scopes` (breaking) — confirmed live on testnet (`testnet-v1.0.11`, 2026-09-18)
A `member-delegation-update` / `OrgDataClient.setDelegation` grant's shape changed, and the node's decoder rejects the old shape outright rather than accepting it silently:
* **`functions: string[]` → `function: string`.** A grant now confers exactly one function. Authorising several functions on the same contract means several grant rows — the SDK's new `fanOutGrant(base, functions)` helper builds that array for you from a shared base. The contract keeps at most one grant per `(grantee, contract_id, function)`; a later write for the same triple replaces the earlier row.
* **`scopes: string[]` + `read_scopes: string[]` → one `scopes: { path, access }[]` list.** `access` is a list of verbs (`"read" | "write" | "delete"`) — only `"read"` is enforced today. Build a read-only list with the new `readOnlyScopes(paths)` helper. A grant written under the old shape may still echo its `read_scopes` value as `legacy_read_scopes` on a read; new writes never set it.
* **`updateMemberDelegation`'s read-merge-write key is now `(grantee, contract_id, function)`**, not `(grantee, contract_id)` — keying on the pair alone would let granting a second function on an already-granted contract silently drop the first function's row.
* The **org delegation edge** (`OrgDataClient.setDelegation`) moved to the same `function` + `scopes` shape; it still carries no `version_req`/`allowed_hosts`/`window`.
**SDK version at this release: `5.19.0`** (not the `5.17.0` some intermediate PR descriptions cite — two more minor bumps landed between that PR and the tagged release; always cite the tag's own `package.json`). See [Member Delegation](/developers/adk/get-started/member-delegation) for the updated grant examples.
`client/mcp/t3n-mcp`'s `getDelegationInstructions` still advises the retired shape as of this release — a known gap on the MCP side, not fixed here. If you're driving delegation through the MCP sidecar rather than the SDK directly, use the shape on this page, not what that tool currently prints.
### Agent lifecycle: create with initial credits, fund, update, delete, deregister — confirmed live on testnet (`testnet-v1.0.10`, 2026-09-15)
The agent lifecycle is now complete end to end. All of the below dispatch through `tee:organisation/contracts` or `tee:agent-registry/contracts` and are gated by contract-version preflights that throw (not silently no-op) against an older node.
* **`createAgent(orgDid, name, options?)`** mints an org-owned agent and, since `5.16.0`, accepts `options.initialCredits` to pre-fund it **in the same transaction**, from the organisation's own T3N balance. A freshly created agent otherwise starts with zero credit and its first metered call fails. Needs `tee:organisation/contracts` ≥ 0.7.0 for `initialCredits`; the call throws rather than creating an unfunded agent silently against an older contract.
* **`fundAgent(agentDid, amount, options?)`** (new in `5.16.0`) tops up an agent's balance any time after creation — from the caller's own balance by default, or from `options.fromOrg`'s balance (requires you to admin `fromOrg`, and the agent must actually be a member of it). Returns `{ seqNo }`, the ledger transaction's sequence number. See [Common Errors](/developers/adk/tips/common-errors#agent-credit-funding-createagent-s-initialcredits-fundagent) for the full authorisation-refusal table — refusals are always identity-based first, balance-based second.
* **`deleteAgent(orgDid, agentDid)`** (`5.8.0`) tears down an org-owned agent — sweeps every org-data row it holds and deletes its registry record in one transaction. Its API keys stop authenticating immediately: the delete removes the key's *principal* (its DID-classification row), not each key individually. Needs `tee:organisation/contracts` ≥ 0.20.0.
* **`updateAgent(orgDid, agentDid, { agentUri?, ownerDid? })`** (`5.8.0`) is an admin-side edit of an agent's registry record; at least one of `agentUri` / `ownerDid` is required. Needs `tee:organisation/contracts` ≥ 0.20.0.
* **`deregisterAgent(agentDid?)`** (`5.8.0`) is how an agent removes its own registry footprint (registry record + any hosted cards). Omit `agentDid` to act on the caller's own session. Needs `tee:agent-registry/contracts` ≥ 1.7.0.
CLI equivalents ship in the same `t3n` binary: `t3n agent create --org --name [--initial-credits ]`, `t3n agent fund --agent --amount [--note ] [--from-org ]`, `t3n agent delete --org `, `t3n agent update --org [--uri ] [--owner ]`, `t3n agent deregister []`.
**Known gap:** there is currently no self-service way to fund an *organisation's own* balance — only a cluster operator can. A brand-new org starts at zero, so `--initial-credits` / `{ initialCredits }` on a fresh org fails with an insufficient-credit error; that's expected, not a bug.
### Trust anchor: `resolveTrustAnchor()`, the RTMR1 measurement, and SEV-SNP support — confirmed live on testnet (`testnet-v1.0.10`, 2026-09-15)
* **`resolveTrustAnchor(env, opts?)`** is a new one-call convenience over `fetchTrustedManifest` — same verification, less boilerplate. Recommended default for constructing `T3nClient`'s `trustAnchor`.
* **`TrustAnchor` gained a required `rtmr1_allowlist` field.** The node now measures rootfs integrity via dm-verity + a Unified Kernel Image, landing in RTMR1 — this is the real signal going forward. `rtmr3_allowlist` is kept for backward compatibility only (the boot script no longer extends RTMR3). See [Verify the trust anchor](/developers/adk/tips/verify-trust-anchor).
* **Attestation is now vendor-aware: TDX and AMD SEV-SNP.** New optional trust-manifest fields `sev_snp_measurement_allowlist` / `sev_snp_product`, and new SDK exports `verifyDkgAttestation` (vendor-dispatching), `verifySevSnpReport`, `detectQuoteVendor`, `sevSnpVcekUrl`, `fetchSevSnpCollateral` — additive, alongside the existing TDX-only `verifyTdxQuote`. `resolveTrustAnchor`/`fetchTrustedManifest` handle either vendor transparently; most integrations against the shared testnet/production clusters won't need the lower-level exports directly. Ships as part of the same release's AWS bare-metal SEV-SNP deployment support (trinity#2183).
### Organisation usage & delegation — confirmed live on testnet (`testnet-v1.0.10`, 2026-09-15)
* **`getOrgUsageReport(input)`**, **`getOrgWorkflowExecutionsForWindow(orgDid, fromMs, toMs)`**, and **`getOrgActivityCountForWindow(orgDid, fromMs, toMs)`** (via `createUsageReportClientFromSession`) replace an earlier, since-superseded set of call-count helpers shipped and renamed within this same release cycle (`getOrgCallCount*`, `getOrgAgentCallCountForWindow` — neither exists at `testnet-v1.0.10`; don't reference them). `getOrgWorkflowExecutionsForWindow` counts protected-workflow executions (`z:`/`y:` client-contract calls, any outcome); `getOrgActivityCountForWindow` counts every call, any contract, any outcome. Both require org-admin and both throw (rather than silently truncating) when a window is wider than the node scans in one call.
* **`getDelegation()`** replies now include `grantee_kinds` (`"agent" | "human" | "org"` per grantee). New **`countAgentsByContract(input)`** / **`countAllAgentsByContract(input)`** count delegated agents per contract for an org, single-page or drained to exhaustion. Needs org-data ≥ 2.11.0.
* **`tenant.setDefaultQuotas(opts)`** / **`tenant.updateQuotas(opts)`** set an organisation's default per-agent quotas and override them for a specific agent, respectively.
### AP2 host interface removed — confirmed on testnet (`testnet-v1.0.10`, 2026-09-15)
The `ap2` (`ap2/mandate`) WIT host interface — previously listed as "Available" on this site — was removed from the runtime entirely: the interface, its linker world, and the backing KV policy row are gone. A contract that still imports `ap2/mandate` will fail to instantiate. No replacement has shipped as of this release. See [Host API](/t3n/how-t3n-works/host-api).
### Error responses: JSON envelope everywhere, clearer egress denials — confirmed live on testnet (`testnet-v1.0.10`, 2026-09-15)
Framework-level rejections (malformed query strings, undecodable path segments, oversized request bodies) that used to come back as raw `text/plain` are now re-encoded into the standard `{ error, code, request_id }` JSON envelope, under the original HTTP status. Separately, the egress-denied error for a `member_delegation` scope mismatch now names **both** the rejected host(s) and the actual resolved allowlist, so a scheme/port mismatch is visible directly in the message instead of requiring a guess. See [Common Errors](/developers/adk/tips/common-errors).
### Delegation vocabulary — already live; the old names are deprecated, not yet removed
Everything that used to be called a *grant* or *agent auth* on the request
surface is now a *delegation*. The two edges are `member_delegation` (a member
delegating their own authority) and `org_delegation` (an organisation granting
authority over its contracts).
**Client methods**
| Deprecated (still works today) | Current |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `getAgentAuth` | `getMemberDelegation` |
| `updateAgentAuth` | `updateMemberDelegation` — the leading `agentDid` argument is gone; the `grantee` now lives on the grant itself |
| `agentAuthUpdate` | `memberDelegationUpdate` |
| `revokeAgentAuthForOrg` | `revokeMemberDelegationForOrg` |
| `OrgDataClient.grantsGet` | `getDelegation` |
| `OrgDataClient.setGrants` | `setDelegation` |
| `OrgDataClient.deleteGrants` | `deleteDelegation` |
| `OrgDataClient.removeMemberGrants` | `removeMemberDelegation` |
**Types**
| Deprecated (still works today) | Current |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `UserGrant` | `BoundGrant` |
| `OrgContractGrants` / `isOrgContractGrants` | `OrgContractDelegation` / `isOrgContractDelegation` |
| `SetGrantsInput` / `DeleteGrantsInput` / `GrantsGetInput` | `OrgDelegationSetInput` / `OrgDelegationDeleteInput` / `OrgDelegationGetInput` |
| `RemoveMemberGrantsInput` | `OrgRemoveMemberDelegationInput` |
| `AgentAuthPolicy` and the `delegation/agent-auth` module | `MemberDelegationDoc`, `parseMemberDelegationDoc`, `mergeGrants`, `mergeDiscoverDids` from `delegation/member-delegation` |
**Raw `execute` callers** — if you build the wire payload yourself rather than
going through a client method, the function names and the body both changed:
| Old | New |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `agent-auth-update` / `agent-auth-get` / `agent-auth-revoke-org` | `member-delegation-update` / `member-delegation-get` / `member-delegation-revoke-org` |
| `org-policy-set-grants` / `org-policy-delete-grants` / `org-grants-get` / `org-remove-member-grants` | `org-delegation-set` / `org-delegation-delete` / `org-delegation-get` / `org-remove-member-delegation` |
| nested `agents[].scripts[]` | flat `grants[]` |
| `agent_did` / `user_did` | `grantee` |
| `script_name` | `contract_id` |
| flat `valid_from_secs` / `valid_until_secs` | `window { … }` |
camelCase keys (`agentDid`, `scriptName`, `validFromSecs`) are rejected outright
rather than silently dropped, so a mistyped time-box can no longer be lost on
the wire.
**Verdicts** — `checkDelegation()` returns the node's verdict as received. Grant
kinds are `member_delegation` / `org_delegation` (was `agent_auth` / `user_grant`)
and the deny codes are `member_delegation_not_found` / `_expired` /
`_not_yet_valid` (was `agent_auth_*`).
Hackathon integrations have referenced `@terminal3/t3n-sdk` versions `3.5.2`, `3.9.0`, and `3.11.0` in the wild. We haven't cross-checked these against an official release history yet, so we're not listing per-version changes here until we can confirm them — a placeholder is worse than an honest gap.
### Audit log CSV export — confirmed live on testnet (`testnet-v1.0.9`, 2026-08-27)
Three new `T3nClient` methods export your organisation's activity audit log to CSV, built on the existing `getActivityLog` read:
* **`client.startActivityLogExport(opts?)`** takes the same scope/filters as `getActivityLog` and starts a background export, returning an `export_id`. Only one export runs at a time per caller — starting a second while one is in flight is refused.
* **`client.getActivityLogExportStatus(exportId)`** reports `pending` / `ready` / `failed`. Only your own export ids resolve — someone else's reads as absent, not as a permissions error.
* **`client.downloadActivityLogExport(exportId)`** fetches the finished CSV in one response. Stays fetchable until your next export replaces it.
* **`client.exportActivityLog(opts?, poll?)`** is a convenience wrapper: starts the export, polls until ready (default 1s interval, 5 minute timeout, both configurable via `poll`), and returns the file. Use the three calls above directly when a UI needs to own its own polling — e.g. to survive a page reload.
The exported CSV's columns are a fixed wire format: `seq_no`, `timestamp_ms`, `caller_type`, `actor`, `on_behalf_of`, `org`, `contract`, `function`, `outcome`. Notably absent: `roles` (doesn't fit a fixed column) and the per-row `hash` from the JSON read.
`getAuditEvents()` and `getActivityLog()` themselves predate this export feature and were already real, public `T3nClient` methods — they just hadn't been written up here yet. Both are now in the [SDK & API Reference](/developers/adk/reference) table alongside the export methods.
# Member Delegation
Source: https://docs.terminal3.io/developers/adk/get-started/member-delegation
How a member delegates scoped authority to an AI agent (or another principal), and how an agent authenticates to T3N.
This page covers two separate things that are easy to conflate: an agent **authenticating** to T3N (proving who it is), and an agent being **authorized** to call a specific contract function (proving it's allowed to). If you know OAuth, delegation is the closest equivalent — a scoped, revocable grant a user hands out — except the grantee is an agent acting on its own, not a third-party app redirecting a user through a consent screen.
This is about **agents** acting through your contract. If you're looking for how *you*, the tenant developer, authenticate to manage your own deployment, see [Set Up Development Environment](/developers/adk/get-started/prerequisites/set-up-dev-env) instead — that's a different session.
This page assumes the agent **already has a DID**. Creating that identity in the first place — minting the agent and publishing its agent card — is [Agent Onboarding](/developers/agents/register-agent), which happens before anything on this page.
That identity also needs its **own** test credits before any metered call on this page will work — an agent DID's balance is separate from its tenant's and starts at zero. Get it a key from the same [claim page](/developers/adk/get-started/prerequisites/request-test-tokens) you used for your own; it comes with credits attached.
## 1. An agent authenticates like any other T3N session
An agent has its own identity — its own key pair and its own DID — separate from the tenant that owns the contract it's calling. It authenticates the same way any T3N client does: handshake, then authenticate, then read its own DID back from the session. There's nothing tenant-specific to configure here.
```typescript theme={null}
import {
T3nClient,
loadWasmComponent,
createEthAuthInput,
eth_get_address,
metamask_sign,
fetchTrustedManifest,
} from "@terminal3/t3n-sdk";
const agentKey = process.env.AGENT_KEY!; // a separate key for the agent — never reuse your tenant's T3N_API_KEY
const agentAddress = eth_get_address(agentKey);
const agentClient = new T3nClient({
trustAnchor: await fetchTrustedManifest("testnet"),
wasmComponent,
handlers: {
EthSign: metamask_sign(agentAddress, undefined, agentKey),
},
});
await agentClient.handshake();
const agentDid = await agentClient.authenticate(createEthAuthInput(agentAddress));
```
Never hard-code or derive an agent's DID any more than you would a tenant's — always read it back from the authenticated session, exactly as `agentDid` is read above. And generate `AGENT_KEY` as its own separate credential (the same way you'd generate any Ethereum-style keypair) — don't reuse your tenant's `T3N_API_KEY` for an agent.
## 2. Being authenticated is not being authorized
Authenticating proves the agent's identity. It does **not** grant it permission to call anything. Before an agent can invoke a contract function — especially one that makes an outbound HTTP call — the **user who owns the data** (the "data owner," not the agent, and not you as the tenant developer) has to explicitly grant that agent access:
```typescript theme={null}
// Signed by the user (data owner), not the agent.
await userClient.execute({
contract_id: "tee:user/contracts",
contract_version: userContractVersion,
function_name: "member-delegation-update",
input: {
grants: [
{
grantee: agentDid, // the agent being authorized
contract_id: TENANT_CONTRACT, // z::your-contract-tail
version_req: contractVersion,
function: "search-offers", // exactly one function per grant row
scopes: [{ path: "search-offers", access: ["read"] }],
allowed_hosts: ["api.duffel.com"], // exactly which external hosts
},
{
grantee: agentDid,
contract_id: TENANT_CONTRACT,
version_req: contractVersion,
function: "book-offer", // a second function → a second row
scopes: [{ path: "book-offer", access: ["read"] }],
allowed_hosts: ["api.duffel.com"],
},
],
},
});
```
`member-delegation-update` **replaces the member's entire delegation document.** Whatever you pass as `grants` becomes the complete new policy, so a write like the one above drops every *other* agent's grants, and `grants: []` revokes all delegated access. To change one grant while leaving the rest intact, read the current policy first (`member-delegation-get`) and write back the merged list — or let the SDK do it for you.
The same applies to `discover_dids`: it is a document-level field, so a write that omits it clears the member's whole discovery list. Read-merge-write, or omit it deliberately.
To add or change grants safely, use the SDK's `updateMemberDelegation`. It reads the current policy, merges your grant(s) by `(grantee, contract_id, function)`, and writes the whole document back, so every other grant survives. It accepts a single grant or an array — pass one row per function, or build the array with the SDK's `fanOutGrant` helper when several functions share everything else:
```typescript theme={null}
// `userClient` is the data owner's authenticated T3nClient (see below).
import { fanOutGrant, readOnlyScopes } from "@terminal3/t3n-sdk";
await userClient.updateMemberDelegation(
fanOutGrant(
{
grantee: agentDid,
contract_id: TENANT_CONTRACT,
version_req: contractVersion,
scopes: readOnlyScopes(["search-offers", "book-offer"]),
allowed_hosts: ["api.duffel.com"],
},
["search-offers", "book-offer"],
),
);
```
**Why "member delegation".** The name is for the **delegator**: a member (the data owner) delegates a scoped slice of their own authority to a *grantee*. The grantee is a member too — identified by a DID, whether that's an AI agent (as above) or the member's own DID for a direct call (a self-grant). The same `member-delegation-update` / `member-delegation-get` contract functions (SDK: `updateMemberDelegation` / `getMemberDelegation`) cover both. This page was previously titled "Agent Auth".
`userClient` here is the data owner's own authenticated session — built the same way as `t3n`/`agentClient` above, just with the user's own key. See [Invoke your contract](/developers/adk/get-started/walkthrough/invoke-contract) for the full construction and where `TENANT_CONTRACT`/`contractVersion` come from.
A grant is scoped three ways at once: which contract, which function on it, and which external hosts it may reach. An agent with no matching grant can still call the contract — the call just fails at the point it tries to reach the network, with `host/http.egress_denied`.
### What a grant can say
Every field is snake\_case on the wire, exactly as written here — camelCase keys
(`agentDid`, `scriptName`, `validFromSecs`) are rejected rather than silently
dropped, so a mistyped time-box can't be lost.
**Grant shape changed in `testnet-v1.0.11` (`@terminal3/t3n-sdk` 5.19.0+).** A grant now confers exactly **one** function — authorising several functions on the same contract means several grant rows, each carrying its own `scopes` (fan out with the SDK's `fanOutGrant` helper, shown above). The contract keeps at most one grant per `(grantee, contract_id, function)`; a later row with the same triple replaces the earlier one. This replaces the earlier shape, where one grant held a `functions` array plus a parallel `read_scopes` cap. The old shape is now rejected outright (the node's decoder is strict about unknown/missing fields) — a client still on the old shape needs `@terminal3/t3n-sdk` 5.19.0 or newer.
| Field | Required | Meaning |
| ----------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `grantee` | yes | The principal this grant applies to — an agent's DID, or the member's own for a self-grant. |
| `contract_id` | yes | The target contract. `"*"` is accepted but authorises only the core-allowlist contracts, never your own tenant contract — name it exactly. |
| `function` | yes | The single WIT function this grant confers (e.g. `"search-offers"`), or `"*"` for every function on that contract. One function per grant row — see the note above. |
| `scopes` | yes | Data scope paths the grantee may access via `function`, as a list of `{ path, access }` entries. `access` is a list of verbs (`"read"` \| `"write"` \| `"delete"`) — **only `"read"` is currently enforced**; don't treat granting or withholding `"write"`/`"delete"` as a security boundary yet. Build a read-only list with the SDK's `readOnlyScopes(paths)`. |
| `version_req` | no | A semver requirement on the target contract version. Omitted matches any version. |
| ~~`read_scopes`~~ | *(retired)* | The old per-grant cap-subset mechanism, removed in `testnet-v1.0.11`. Since a grant now covers one function already, express a function's narrower read access as that function's own `scopes` instead. A grant written before this change may still echo its old value as `legacy_read_scopes` on a read — informational only, and new writes never set it. |
| `allowed_hosts` | no | The egress hosts this grant lets the contract reach. Omitted means none — which is what produces `host/http.egress_denied`. |
| `window` | no | A validity time-box, `{ valid_from_secs, valid_until_secs }`. Both bounds are optional unix-seconds; an omitted upper bound is capped by the operator, not left open. |
### Org delegation — the second edge
Everything above is the **member** edge: a data owner delegating their own
authority. There is a second, independent edge — **org delegation** — where an
organisation admin grants authority over the org's contracts, written with the
SDK's `OrgDataClient.setDelegation` rather than `updateMemberDelegation`.
A call on the delegated path needs **both** edges to permit it. They are
enforced separately rather than as one combined check, so a missing org grant
and a missing member grant fail in different places. An org grant also carries
less: only `function` and `scopes` — the qualifiers `version_req`,
`allowed_hosts` and `window` are rejected on that edge, so an org
grant cannot carry an expiry or a host allowlist. `checkDelegation()` reports
which edge is missing for a given action. `getDelegation()`'s reply additionally
carries `grantee_kinds` — one `"agent" | "human" | "org"` per grantee, so a
single read partitions a contract's grantees without a separate roster call.
Splitting authentication from authorization means a compromised or misbehaving agent key doesn't automatically mean compromised data access — the blast radius of a leaked agent key is exactly whatever contracts, functions, and hosts a user has explicitly granted it, nothing more. It also means a user can revoke an agent's access without the agent's key changing at all — they just stop re-issuing the grant.
## 3. Stateless invocation with invoke()
Everything above assumes a session: `handshake()` then `authenticate()`, held open across calls. An org-owned agent has a second, stateless option — `invoke()` posts once to `POST /api/invoke` and authenticates by an opaque API key instead, with **no handshake, no `Session-Id`, no session state at all**:
```typescript theme={null}
import { invoke, InvokeError } from "@terminal3/t3n-sdk";
try {
const result = await invoke({
baseUrl: "https://cn-api.sg.testnet.t3n.terminal3.io",
apiKey: process.env.AGENT_API_KEY!, // t3n_key_<...> — see Provision an org-owned agent
request: {
contract_id: TENANT_CONTRACT,
contract_version: contractVersion,
function_name: "search-offers",
input: { origin: "LHR", destination: "JFK", departure_date: "2026-07-15", cabin_class: "economy", adult_count: 1 },
},
});
} catch (err) {
if (err instanceof InvokeError) {
// A fixed, generic message — never the raw api key, response body, or
// underlying network error. Safe to log as-is.
console.error(err.message, err.status);
}
throw err;
}
```
The agent presents its key verbatim in the `X-T3N-Api-Key` header (`t3n_key_.`) — the same key printed once when you minted the agent, see [Register an Organization-owned Agent](/developers/agents/provision-org-agent). The node derives the acting agent DID from the key per call and runs the same execution pipeline the session `execute` path runs, returning the contract's decoded result directly (no JSON-RPC envelope to unwrap).
`invoke()` refuses to send the api key over anything but HTTPS, unless the host is a local-dev loopback (`localhost` / `127.0.0.1` / `[::1]`) — an insecure `baseUrl` throws before any network activity. This is a fixed transport rule, not something you configure.
Use `invoke()` for a one-shot call from somewhere a long-lived session doesn't fit — a webhook handler, a serverless function, a cron job — and the session-based flow above (`T3nClient.handshake()`/`authenticate()`) for anything that makes several calls and can hold a session open. `pii_did` on the request is the delegated-call target — set it to act on behalf of another org member; omit it for a self call.
## Direct (self) calls work the same way
If a user is invoking their own contract directly rather than through a separate agent, the same grant mechanism applies — they just grant to their own DID (a self-grant) instead of an agent's.
## Full working example
See [Invoke your contract](/developers/adk/get-started/walkthrough/invoke-contract) for this in context, including the search/book contract call itself. For why the outbound call fails without a grant even when the contract code is correct, see [Outbound HTTP calls are authorized by the user, not the contract](/developers/adk/tips/outbound-http-auth-by-user).
Older community code sometimes references a standalone *delegation-credential* API — functions for building and signing a per-call delegation credential (an "envelope"). That flow was removed when T3N moved to **envelope-free** authorisation, and it is not part of the current SDK (confirmed against `testnet-v1.0.11`, `@terminal3/t3n-sdk` 5.19.0). Authority is now the standing on-chain member delegation shown above (intersected with any org-granted authority), so `member-delegation-update` — not a per-call credential — is the write surface.
# Claim Your API Key & credits
Source: https://docs.terminal3.io/developers/adk/get-started/prerequisites/request-test-tokens
Claim your account, key, and test credits in one step — self-serve, no approval.
Eager to start building on our ADK? You will need test credits to use in your contracts and for interactions with the T3 Network.
**No approval, no waitlist.** This is fully self-serve. Sign in with your work email and your developer key plus test credits are issued instantly — you do not need to request access or be granted anything first.
Head over to the [claim page](https://www.terminal3.io/claim-page) and sign in with your work email.
Coming from an event or a campaign? Enter your campaign code to receive additional credits (the amount depends on the campaign).
Once you submit the form, your developer key appears immediately. This is the key the SDK uses to authenticate as you — save it somewhere safe.
During the test phase, the key is shown only once and can't be retrieved after you leave the page. Copy it before you navigate away.
Your unique T3N ID (`did:t3n`) and test credits are generated and linked to this key automatically.
That's it — you're ready to build. Let's [set up your development environment](/developers/adk/get-started/prerequisites/set-up-dev-env), then follow the [walkthrough](/developers/adk/get-started/walkthrough/write-contract).
If you encounter any issues during this process, please refer to [Common errors](/developers/adk/tips/common-errors) or do not hesitate to drop us an email at [devrel@terminal3.io](mailto:devrel@terminal3.io).
# Set Up Development Environment
Source: https://docs.terminal3.io/developers/adk/get-started/prerequisites/set-up-dev-env
Two more steps to get ready for writing your first TEE contract.
This page picks up where [Quickstart](/developers/adk/get-started/quickstart) leaves off. Complete that first — you should already have an authenticated `T3nClient` and your `tenantDid` before starting here.
If you haven't already, get your DID, download your API key, and claim test credits from the [claim page](/developers/adk/get-started/prerequisites/request-test-tokens) — it's self-serve, no approval needed.
[TEE contracts](/t3n/how-t3n-works/tees#tee-contract) are compiled to WebAssembly (WASM) binaries, built with the Rust toolchain.
If you don't already have `rustup`/`cargo` installed:
```bash theme={null}
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # choose the default option when prompted
source "$HOME/.cargo/env"
```
Then add the WASM target:
```bash theme={null}
rustup target add wasm32-wasip2 # WASI Preview 2 build target — a few seconds
cargo install wasm-tools # optional — inspect/verify the component
```
`cargo install wasm-tools` compiles roughly 100 crates from source and takes about 2 minutes with no progress output in between — that's normal, not a hang.
Contracts are registered and managed through a `TenantClient`, built around the `tenantDid` you already obtained in Quickstart — never construct or derive this value yourself.
Append this to the bottom of the same `quickstart.ts` you created in Quickstart — it needs `t3n` and `tenantDid` from that file's scope. See [Running the code samples in this guide](/developers/adk/tips/running-code-samples) for the `cat >> quickstart.ts << 'EOF'` pattern:
```bash theme={null}
cat >> quickstart.ts << 'EOF'
import { TenantClient, getNodeUrl } from "@terminal3/t3n-sdk";
const tenant = new TenantClient({
t3n, // the T3nClient you already authenticated in Quickstart
baseUrl: getNodeUrl(), // the active node from setEnvironment()
tenantDid, // did.value from Quickstart — never hardcode
});
await tenant.tenant.me(); // throws if something's wrong; confirms the client actually works
console.log("TenantClient ready.");
EOF
```
**`me()` lives on `tenant.tenant`, not `tenant` directly.** `TenantClient` exposes `tenant`/`maps`/`contracts`/`token` as sub-namespaces — `me()` and `claim()` are on the `tenant` sub-namespace (`TenantNamespace`), so the call is `tenant.tenant.me()`.
Run it the same way as before — `npx tsx quickstart.ts` — and confirm you see `TenantClient ready.` printed after your `tenantDid` line.
**This authenticates you to manage your own deployment** — a different job from agent authentication. The DID must equal the one admitted as a tenant in `idx:_tenants`: exactly the `tenantDid` you already have from Quickstart.
## What's next
Continue to [Write your first TEE contract](/developers/adk/get-started/walkthrough/write-contract).
# Quickstart
Source: https://docs.terminal3.io/developers/adk/get-started/quickstart
Get your first authenticated call to Terminal 3 working in under 10 minutes.
This page gets you to one working, authenticated call — nothing else. No Rust, no WASM, no blockchain knowledge required for this part. Once this works, move on to [Write your first TEE contract](/developers/adk/get-started/walkthrough/write-contract) for the real thing.
Go to the [claim page](/developers/adk/get-started/prerequisites/request-test-tokens) and sign in. You get an API key and test credits instantly — no approval wait.
Your key is shown **once**. Copy it somewhere safe before you navigate away — there's no way to view it again.
Create a fresh project and install the SDK:
```bash theme={null}
mkdir my-t3n-app && cd my-t3n-app
npm init -y
npm pkg set type=module # required — the code below uses top-level await
npm install @terminal3/t3n-sdk tsx
```
**Don't pin an old version by habit.** `@terminal3/t3n-sdk` ships frequently (breaking grant-shape and other changes land in minor versions — see the [Changelog](/developers/adk/changelog)), so `npm install @terminal3/t3n-sdk` with no version suffix — always latest — is the right default here. Pin an exact version only once you've deliberately decided to hold back, and check the changelog when you do bump.
Then put the key from Step 1 into your shell's environment — the code in the next step reads it from there, it isn't written into any file:
```bash theme={null}
export T3N_API_KEY=""
```
A handful of teams have hit WASM-loading errors under Next.js/Turbopack, Vite, and older Webpack setups — the SDK loads a WASM component, and some bundlers try to process it in ways that break it. If you hit a WASM parse or module-loading error later in this guide and you're in a bundled framework, try running the SDK code from a plain Node script or a server-only route first, to confirm your setup works before debugging the bundler. If you're on Next.js specifically, adding your bundler's equivalent of an external-packages/no-bundle exception for `@terminal3/t3n-sdk` is worth trying. We're tracking this as a known rough edge — if you find a config that fixes it, tell us in the [developer Telegram](https://t.me/terminal3developer) and we'll document it here properly.
Create a file named `quickstart.ts` in your project and paste this in. Don't paste the code directly at your shell prompt — see [Running the code samples in this guide](/developers/adk/tips/running-code-samples) for the `cat > quickstart.ts << 'EOF'` pattern this and every later step assumes:
```typescript theme={null}
import {
T3nClient,
setEnvironment,
loadWasmComponent,
resolveTrustAnchor,
eth_get_address,
metamask_sign,
createEthAuthInput,
} from "@terminal3/t3n-sdk";
setEnvironment("testnet"); // the public SDK defaults to testnet — set it explicitly so your target cluster is unambiguous (and switch to "production" when you go live)
const T3N_API_KEY = process.env.T3N_API_KEY!;
const wasmComponent = await loadWasmComponent(); // all crypto runs inside this component
const address = eth_get_address(T3N_API_KEY);
const t3n = new T3nClient({
// Verifies you're really talking to a genuine T3N enclave, not just
// whatever the server claims — see "Verify the trust anchor" below.
// Node URL comes from setEnvironment above.
trustAnchor: await resolveTrustAnchor("testnet"),
wasmComponent,
handlers: {
EthSign: metamask_sign(address, undefined, T3N_API_KEY),
},
});
await t3n.handshake();
const did = await t3n.authenticate(createEthAuthInput(address));
const tenantDid = did.value; // did:t3n:... — you'll reuse this exact variable in every later step
console.log("Connected as:", tenantDid);
```
**`trustAnchor` is required — `T3nClient` throws immediately if it's missing.** `resolveTrustAnchor(env)` is the right call for any hosted node; the only other valid value is the explicit opt-out `{ unsafe_trust_server: true }`, for local dev nodes only. See [Verify the trust anchor](/developers/adk/tips/verify-trust-anchor) for what it's actually checking.
**Never hardcode or derive your tenant DID.** It's not related to your wallet address — it's an opaque ID the platform assigns you the first time you sign in. Always read it back from the authenticated session, like `tenantDid` above — and keep both `t3n` (the client) and `tenantDid` (the value) around, since [Set Up Dev Env](/developers/adk/get-started/prerequisites/set-up-dev-env) and the rest of the walkthrough build directly on top of them.
**"Reuse this variable" means literally keep coding in this same file.** `t3n` and `tenantDid` are plain JavaScript variables — they only exist inside this one running script. The code samples on later pages (`TenantClient`, contract registration, invocation) assume `t3n`/`tenantDid`/`tenant` are already in scope, so append each new snippet to the bottom of this same `quickstart.ts` rather than starting a new file, unless you deliberately repeat the connect-and-authenticate block above at the top of it.
`setEnvironment("testnet" | "production")` tells the SDK which T3N cluster to resolve a node URL from for every client you create afterward — you never hardcode a node URL yourself. Its public build defaults to `"testnet"`; setting it explicitly keeps your target cluster unambiguous and makes the switch to `"production"` a deliberate one-line change.
From the same directory as `quickstart.ts`:
```bash theme={null}
npx tsx quickstart.ts
# Connected as: did:t3n:9f2a...
```
If you see a `did:t3n:...` value printed, you're connected.
1. Get your API key from the [claim page](/developers/adk/get-started/prerequisites/request-test-tokens) — shown once, copy it somewhere safe.
2. Save our [skill file](/developers/adk/support/ai-coding-assistants) as `.claude/skills/t3n-adk-quickstart/SKILL.md` in your project.
3. Describe what you're building. Claude Code scaffolds the project (`package.json`/ESM setup, your API key in the environment), writes and runs the connection code, and checks a known-pitfalls list before guessing at fixes.
You should still end up with a working `t3n` client and `tenantDid` — the same things the manual path produces — just without typing the steps yourself.
1. Get your API key from the [claim page](/developers/adk/get-started/prerequisites/request-test-tokens) — shown once, copy it somewhere safe.
2. Save the same [skill file](/developers/adk/support/ai-coding-assistants) as `AGENTS.md` in your project root — Codex reads it automatically.
3. Describe what you're building. It follows the same order as the manual path: working connection first, contract code second.
## What's next
You now have two things every later page assumes you already have: an authenticated `t3n` client and its `tenantDid`. Keep both around (same variable names, same file) as you continue:
* Building something that actually runs inside the confidential-compute enclave → [Write your first TEE contract](/developers/adk/get-started/walkthrough/write-contract)
* Letting an AI agent act on a user's behalf → [Member Delegation](/developers/adk/get-started/member-delegation). An agent needs its **own** DID and its **own** test credits — separate from yours, and from the same claim page you used in Step 1 — so get it its own key before writing any agent code: [Register a Public Agent](/developers/agents/register-agent)
* Understanding what `resolveTrustAnchor` actually verifies → [Verify the trust anchor](/developers/adk/tips/verify-trust-anchor)
* The shape of the whole ADK before you dive in further → [ADK Tour](/developers/adk/overview/adk-tour)
* Hit an error? → [Common Errors](/developers/adk/tips/common-errors)
# 2. Build your TEE contract
Source: https://docs.terminal3.io/developers/adk/get-started/walkthrough/build-contract
This step turns the Rust contract from [Step 1](/developers/adk/get-started/walkthrough/write-contract) into a WASM component. Run these commands below from the contract repository root, where `Cargo.toml` and `wit/world.wit` live.
You do not need `cargo-component`. With `crate-type = ["cdylib", "lib"]` in `Cargo.toml`, the `wasm32-wasip2` target emits a WASM component that T3N can inspect and register.
## Build the release artifact
Install the WASI Preview 2 target once per machine, then build the release artifact:
```bash theme={null}
rustup target add wasm32-wasip2
cargo build --target wasm32-wasip2 --release
```
Cargo writes the component to `target/wasm32-wasip2/release/`. If your package name contains hyphens, Cargo converts them to underscores in the file name. The `z-tenant-flight` package therefore builds to:
```bash theme={null}
target/wasm32-wasip2/release/z_tenant_flight.wasm
```
Confirm the file exists before moving on:
```bash theme={null}
ls -lh target/wasm32-wasip2/release/*.wasm
```
The `.wasm` file is the artifact you pass to `tenant.contracts.register` in [Step 3](/developers/adk/get-started/walkthrough/register-contract).
## Verify the component interface
Use `wasm-tools` to print the component's WIT interface:
```bash theme={null}
wasm-tools component wit target/wasm32-wasip2/release/z_tenant_flight.wasm
```
The output should include the host interfaces you imported in `wit/world.wit`, such as `host:interfaces/kv-store`, and your exported interface:
```wit theme={null}
export contracts;
```
If `wasm-tools` is not installed yet:
```bash theme={null}
cargo install wasm-tools
```
# 4. Invoke your TEE contract
Source: https://docs.terminal3.io/developers/adk/get-started/walkthrough/invoke-contract
Agents call your contract via the same `execute` transport as any other T3N contract. The only difference is the `contract_id` starts with `z::`.
**Agents authenticate as themselves, not as tenants.** Like every T3N session, an agent reads its own DID back from the authenticated session — there's nothing tenant-specific to set.
## 1. Set up the agent's identity
An agent is a separate authenticated session with its own key — it is not the tenant and not the user. Build it the same way you built `t3n` in [Quickstart](/developers/adk/get-started/quickstart), but with its own credential:
```typescript theme={null}
import {
T3nClient,
loadWasmComponent,
fetchTrustedManifest,
createEthAuthInput,
eth_get_address,
metamask_sign,
getContractVersion,
getNodeUrl,
fetchTrustedManifest,
} from "@terminal3/t3n-sdk";
const trustAnchor = await fetchTrustedManifest("testnet"); // required — see Verify the trust anchor; reused below for every client in this file
const agentKey = process.env.AGENT_KEY!; // a separate credential — never reuse your tenant's T3N_API_KEY
const agentAddress = eth_get_address(agentKey);
const agentClient = new T3nClient({
trustAnchor: await fetchTrustedManifest("testnet"),
wasmComponent, // node URL resolved from setEnvironment() — see set-up-dev-env
trustAnchor,
handlers: {
EthSign: metamask_sign(agentAddress, undefined, agentKey),
},
});
await agentClient.handshake();
const agentAuth = await agentClient.authenticate(createEthAuthInput(agentAddress));
const agentDid = agentAuth.value; // reused below when the user authorizes this agent
const TENANT_CONTRACT = `z:${tenantDid.slice("did:t3n:".length)}:travel-contracts`;
const contractVersion = await getContractVersion(getNodeUrl(), TENANT_CONTRACT);
```
**Where `AGENT_KEY` comes from.** An agent's key is not derived from your tenant key —
get it the same way you got your own, from the [claim page](/developers/adk/get-started/prerequisites/request-test-tokens).
It needs its **own** test credits too: an agent DID's balance is separate from its
tenant's and starts at zero, so a key generated any other way (or your tenant's key,
reused) will fail metered calls with `InsufficientCreditError`. See
[Register a Public Agent](/developers/agents/register-agent) for the full flow.
## 2. Authorize the contract's egress (as the user)
Before any function that makes an outbound HTTP call can run, the **user (data owner)** must authorize it. A tenant contract's allowed hosts are resolved per-call from the user's authorization grant — not from the contract.
In this walkthrough you're standing in for the user yourself, so build a third session — `userClient` — with its own credential, exactly like `t3n` and `agentClient` above:
```typescript theme={null}
const userKey = process.env.USER_KEY!; // stands in for the real data owner's own key
const userAddress = eth_get_address(userKey);
const userClient = new T3nClient({
trustAnchor: await fetchTrustedManifest("testnet"),
wasmComponent,
trustAnchor, // same anchor as agentClient above — one environment, one manifest
handlers: {
EthSign: metamask_sign(userAddress, undefined, userKey),
},
});
await userClient.handshake();
await userClient.authenticate(createEthAuthInput(userAddress));
```
Now the user signs a `member-delegation-update` scoping the agent to your contract, its functions, and the hosts it may reach:
```typescript theme={null}
// Signed by the USER (data owner), not the agent.
const userContractVersion = await getContractVersion(getNodeUrl(), "tee:user/contracts");
await userClient.execute({
contract_id: "tee:user/contracts",
contract_version: userContractVersion,
function_name: "member-delegation-update",
input: {
grants: [{
grantee: agentDid, // from step 1
contract_id: TENANT_CONTRACT, // z::travel-contracts, from step 1
version_req: contractVersion,
functions: ["search-offers", "book-offer"],
allowed_hosts: ["api.duffel.com"], // hosts the contract may dial
}],
},
});
```
`member-delegation-update` **replaces the member's whole delegation policy.** This raw call is fine here — it's the first grant — but a later single-grant write would drop the earlier ones. To add grants incrementally, call `userClient.updateMemberDelegation(grant)`, which read-merge-writes so existing grants survive. See [Member Delegation](/developers/adk/get-started/member-delegation).
For a **direct (self) call** — where the user invokes the contract themselves rather than through a separate agent — set `grantee` to the user's own DID (a self-grant) instead of building a separate `agentClient`. Without a matching grant the contract still runs, but any outbound call is denied with `host/http.egress_denied`. See [Outbound HTTP is authorized by the user, not the contract](/developers/adk/tips/outbound-http-auth-by-user).
## 3. Invoke your contract (as the agent)
With the grant in place, the agent can call the contract's functions:
```typescript theme={null}
// 1. Search for offers (no PII)
const search = await agentClient.executeAndDecode({
contract_id: TENANT_CONTRACT,
contract_version: contractVersion,
function_name: "search-offers",
input: { origin: "LHR", destination: "JFK", departure_date: "2026-07-15", cabin_class: "economy", adult_count: 1 },
});
const offer = search.offers[0];
// 2. Book the chosen offer. No PII in the input — name, DOB and email are
// resolved host-side from the user's profile via http-with-placeholders,
// and only when the user's grant authorizes this agent (see the grant above).
const booking = await agentClient.executeAndDecode({
contract_id: TENANT_CONTRACT,
contract_version: contractVersion,
function_name: "book-offer",
input: {
offer_id: offer.id,
passenger_id: offer.passenger_ids[0], // opaque Duffel id from search — not PII
total_amount: offer.total_amount,
total_currency: offer.total_currency,
},
});
// booking.pnr → the flight booking reference. The passenger's name never left the enclave.
```
# 3. Register your TEE contract
Source: https://docs.terminal3.io/developers/adk/get-started/walkthrough/register-contract
Registration uploads the WASM component you built in [Step 2](/developers/adk/get-started/walkthrough/build-contract) and gives it a tenant-local name. After this step, T3N knows about your contract and gives you a numeric `contract_id` that you use when creating map ACLs.
This code goes back in `quickstart.ts` — the Node/TypeScript project from Quickstart — not in the `z-tenant-flight` Rust repo. Append it to the bottom, after the `TenantClient` code from [Set Up Dev Env](/developers/adk/get-started/prerequisites/set-up-dev-env), using the `cat >> quickstart.ts << 'EOF'` pattern from [Running the code samples in this guide](/developers/adk/tips/running-code-samples).
Before you run this code, make sure you have:
* An authenticated `TenantClient` named `tenant`. If you have not created one yet, complete [Quickstart](/developers/adk/get-started/quickstart) and [Set Up Dev Env](/developers/adk/get-started/prerequisites/set-up-dev-env) first.
* A compiled WASM file at `target/wasm32-wasip2/release/z_tenant_flight.wasm`, **inside the separate `z-tenant-flight` folder you cloned** in [Write your TEE contract](/developers/adk/get-started/walkthrough/write-contract) — not inside your Node project.
* Your `tenantDid`, for example `did:t3n:abcdef0123456789abcdef0123456789abcdef01`.
## Choose a contract tail
The `tail` is the local name of your contract inside your tenant namespace. Pass only the part after `z::`. For example, the tail `travel-contracts` becomes:
```text theme={null}
z::travel-contracts
```
Do not include `z::` in the `tail`; the SDK and host derive that from the authenticated tenant.
A tail may contain letters, digits, `_`, `-`, and `.` — but **not** `/`. The SDK rejects slashes (`tail must match /^[a-zA-Z0-9_-][a-zA-Z0-9_.-]{0,127}$/`).
Pick a stable tail for each contract you plan to maintain. When you register a new build at the same tail, increase the `version` value; changing the tail creates a separate contract entry.
Keep tails short and simple (a few words, hyphen-separated) rather than long or descriptive. The full canonical name (`z::`) gets reused downstream in places like delegation grants, and a handful of teams have hit unexpectedly-strict length limits further down the pipeline when using long tails. The 128-character limit above is enforced at registration; treat it as a ceiling, not a target.
## Register the WASM
```typescript theme={null}
import { readFile } from "fs/promises";
// Path to the .wasm file INSIDE your cloned z-tenant-flight folder, relative
// to quickstart.ts. Adjust this if you cloned it somewhere else.
const WASM_PATH = "../z-tenant-flight/target/wasm32-wasip2/release/z_tenant_flight.wasm";
const CONTRACT_TAIL = "travel-contracts";
const CONTRACT_VERSION = "0.1.0";
const wasmBytes = await readFile(WASM_PATH);
const result = await tenant.contracts.register({
tail: CONTRACT_TAIL,
version: CONTRACT_VERSION,
wasm: wasmBytes,
});
// This numeric ID is required in the next setup step when you create map ACLs.
const contractId = result.contract_id;
const tenantId = tenantDid.slice("did:t3n:".length);
const contractName = `z:${tenantId}:${CONTRACT_TAIL}`;
console.log(`registered ${contractName} as contract id ${contractId}`);
```
`WASM_PATH` above assumes you cloned `z-tenant-flight` as a sibling folder next to your Node project (i.e. `../z-tenant-flight/...` from `quickstart.ts`) — if you put it somewhere else, update the path accordingly.
Registration does not run your code, create maps, seed secrets, or grant outbound HTTP access. It only stores the component and records the versioned contract entry for your tenant.
## What T3N stores
The register payload is just `{ tail, version, wasm }`; there is no manifest.
Host-side, T3N:
1. Stores the WASM blob in content-addressed storage.
2. Allocates a numeric `ContractId`.
3. Records the contract under your tenant registry.
Your contract's capabilities come from the host interfaces it imports in `world.wit`, not from this registration request. See [Capabilities come from your WIT imports](/developers/adk/tips/capabilities-from-wit-import).
Outbound hosts are also not declared here. They come from the calling user's authorization grant at invoke time. See [Outbound HTTP is authorized by the user, not the contract](/developers/adk/tips/outbound-http-auth-by-user).
**Re-registering a tail allocates a new `contract_id`.** Unpinned calls resolve to the *latest* registered version (that's what `getContractVersion()` returns); if you need a specific version, pass it explicitly to `contracts.execute()` and it will be honored. The real gotcha is the id: there is currently no API to fetch a tail's current `contract_id` after re-registering, so if you created map ACLs scoped to the old `contract_id`, a re-registration can leave them pointing at a stale id. Keep a record of each `contract_id` your tenant registers so you can re-grant map access if needed.
## First-run troubleshooting
| Error or symptom | What it usually means | What to do |
| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ENOENT: no such file or directory` | The WASM path is wrong, or the contract was not built yet — a common cause is `WASM_PATH` pointing into your Node project instead of across to the separate `z-tenant-flight` folder. | Re-run Step 2 and confirm the path with `ls -lh /target/wasm32-wasip2/release/*.wasm`, then check `WASM_PATH` is relative to where `quickstart.ts` actually runs from. |
| `tenant not found` | The session DID does not match an admitted tenant — you constructed or derived `tenantDid` instead of reading it from the session. | Read `tenantDid` from `did.value` after authenticating (see [Quickstart](/developers/adk/get-started/quickstart)), then rebuild `TenantClient` with it. Confirm with `tenant.tenant.me()`. |
| `version is not higher than current version ` | You already registered this tail with the same or a higher version. | Bump `CONTRACT_VERSION`, for example from `0.1.0` to `0.1.1`. |
| The contract registers, but later cannot read `secrets` | The map does not exist yet, or its ACL does not include this `contractId`. | Use the returned `contractId` when creating the `secrets` map ACLs. |
| A previously-working pinned-version call starts failing or behaving unexpectedly | An explicit pinned version is honored by `contracts.execute()`, so a pinned call failing usually means that version was never successfully registered/executable — not that it was shadowed. | Verify the pinned version is actually registered and executable; for the default (unpinned) path, `getContractVersion()` returns the latest registered version. |
| A long contract tail is rejected further downstream (e.g. when building a delegation grant), even though registration succeeded | Some downstream operations enforce a shorter length limit on the full canonical name than registration does. | Prefer a short tail from the start — see the note above. |
The contract is now registered. It still cannot complete the full end-to-end flow until the [maps](/developers/adk/tips/create-kv-maps) and [secrets](/developers/adk/tips/seed-api-key) it reads at runtime exist.
# 5. Test your TEE contract
Source: https://docs.terminal3.io/developers/adk/get-started/walkthrough/test
**Unit tests (Rust):**
Test the business-logic guards on the native target with `cargo test`. Each
function takes the raw input bytes and returns `Result, String>`, so you
call it directly and assert on the error string — there is no `ContractError`
enum or test harness to set up. Functions that call the host (`http`, `kv-store`)
only run on `wasm32`; natively they return an error, so unit tests focus on input
parsing and validation.
```rust theme={null}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn book_offer_rejects_inline_pii() {
// book-offer deserialises into a struct with only opaque ids + amount,
// so a payload that tries to smuggle passenger PII fails at parse time.
let input = serde_json::to_vec(&serde_json::json!({
"offer_id": "off_1",
"passengers": [{ "given_name": "Jane" }], // not a valid field
"total_amount": "199.00", "total_currency": "GBP",
}))
.unwrap();
let err = book_offer(&input).unwrap_err();
assert!(err.contains("bad input"));
}
#[test]
fn book_offer_rejects_non_json() {
assert!(book_offer(b"not json").unwrap_err().contains("bad input"));
}
}
```
**Test checklist:**
* Happy path: `search-offers` → `book-offer` returns `{ id, pnr, status }`.
* Input hygiene: `book-offer` accepts only `offer_id`, `passenger_id`, `total_amount`, `total_currency` — any payload carrying passenger PII is rejected.
* PII never in output: `passport`, `date_of_birth`, and name fields do not appear in any return value or log line.
* The `{{profile.*}}` markers stay literal in the contract — resolution happens host-side, so they never appear resolved in WASM memory.
# What is z-namespace?
Source: https://docs.terminal3.io/developers/adk/get-started/walkthrough/what-is-z-namespace
Before you write a contract, it's worth knowing where your data actually lives. This page covers that in two minutes; the [next page](/developers/adk/get-started/walkthrough/write-contract) starts the real walkthrough, using `Terminal-3/z-tenant-flight` — a Duffel flight booking contract — as a worked example throughout.
## What is z-namespace?
The `z:` namespace is T3N's tenant data layer. Every map and every registered contract you create as a tenant lives under `z::…`, where `` is the 20-byte lowercase hex suffix of your tenant DID.
```
ALL MAPS
│
├── AppKvMap (system data — T3N engineers only)
│ ├── users, auth, dids, scripts, outbox_*…
│ └── idx:_tenants, idx:_tenant_contracts…
│
└── z:: (your data — you own this)
├── z::secrets ← your Duffel API key, sealed inside the enclave
└── z::anything ← DENIED (cross-tenant isolation enforced at every KV op)
```
The cluster enforces one rule at hardware level: a contract can only read or write maps whose prefix matches its own ``. There is no ACL misconfiguration that can break this — the check runs inside the TDX enclave at every transaction.
**What T3N guarantees:**
* Your maps are inaccessible to other tenants and to system contracts.
* User PII (`users` map) is unreachable to your contract's direct reads — PII flows through the `http-with-placeholders` host interface, which substitutes it into outbound requests inside the enclave (see [Placeholders in outbound calls](/developers/adk/tips/placeholders-outbound-calls)).
* Every operation your contract performs lands in the cluster's append-only audit log with your tenant DID as a first-class field — see [Read your organization's activity log](/developers/adk/tips/get-activity-log) for how to query it.
For the full access-control model (readers/writers ACLs per map), see [Storage Namespaces](/t3n/how-t3n-works/z-namespace).
# 1. Write your TEE contract
Source: https://docs.terminal3.io/developers/adk/get-started/walkthrough/write-contract
Clone the reference implementation now rather than typing the Rust code by hand — it's a **separate project** from the Node/TypeScript app you built in Quickstart, so put it in its own folder alongside it, not inside it:
```bash theme={null}
cd .. # out of my-t3n-app, back to a shared parent folder
git clone https://github.com/Terminal-3/z-tenant-flight.git
cd z-tenant-flight
```
Below is a walkthrough of the pieces inside that repo — change the host calls and flight-specific logic to match your needs once you understand them.
A TEE contract is a Rust crate compiled to a WASM **component**. It exports its functions through a `contracts` WIT interface and imports only the host capabilities it needs.
Key concepts and tips before starting:
* [Storage namespace](/t3n/how-t3n-works/z-namespace)
* [Host API](/t3n/how-t3n-works/host-api)
* [Create Tenant KV maps](/developers/adk/tips/create-kv-maps)
* [Capabilities come from your WIT imports](/developers/adk/tips/capabilities-from-wit-import)
## Repository Structure
```
z-tenant-flight/
├── src/
│ ├── lib.rs ← wit-bindgen entry point + Guest impl that dispatches to each fn
│ ├── search.rs ← search-offers — Duffel search (no PII)
│ └── booking.rs ← book-offer — Duffel booking (PII via http-with-placeholders)
├── wit/
│ ├── world.wit ← the world your contract exports + the host interfaces it imports
│ └── deps/ ← vendored host interface packages (host-interfaces, host-tenant)
└── Cargo.toml
```
The packages under `wit/deps/` define the host ABI your contract links against — vendor the versions your target cluster provides (here, `host-interfaces-2.2.0/` and `host-tenant-1.2.0/`).
## Files
### world.wit — declare your interface + host imports
```wit theme={null}
package z:tenant-flight@0.4.0;
world tenant-flight {
import host:tenant/tenant-context@1.2.0;
import host:interfaces/logging@2.2.0;
import host:interfaces/kv-store@2.2.0;
import host:interfaces/http@2.2.0; // search (no PII)
import host:interfaces/http-with-placeholders@2.2.0; // booking (PII via placeholders)
export contracts;
}
interface contracts {
// Uniform 3-field envelope used by every node-callable contract.
// input — JSON arguments for this function, as bytes
// user-profile — None for tenant contracts (profile is resolved host-side)
// context — node-minted DynamicContext (trusted), as bytes
record generic-input {
input: option>,
user-profile: option>,
context: option>,
}
// One func per operation. Each takes generic-input and returns JSON bytes on
// success, or an error string. There is no central `dispatch` function and no
// `ContractError` enum — the function name *is* the export.
search-offers: func(req: generic-input) -> result, string>;
book-offer: func(req: generic-input) -> result, string>;
}
```
[The interfaces you import here are your contract's entire capability set](/developers/adk/tips/capabilities-from-wit-import) — there is no separate manifest. The host links your contract against the matching tenant world and refuses to load it if it imports an interface that world does not provide.
### Cargo.toml — compile to a WASM component
```toml theme={null}
[package]
name = "z-tenant-flight"
version = "0.4.1"
edition = "2021"
# crate-type cdylib is what makes the wasm32-wasip2 target emit a WASM
# *component* (not a bare module). Keep "lib" too so the business logic
# stays unit-testable natively.
[lib]
crate-type = ["cdylib", "lib"]
[dependencies]
# wit-bindgen's macro generates the bindings from wit/ at compile time.
wit-bindgen = { version = "0.49", default-features = false, features = ["macros", "realloc"] }
serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] }
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
# Small, self-contained artifact — keeps registration under the size cap.
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = true
```
### lib.rs — generate bindings + dispatch to each function
```rust theme={null}
wit_bindgen::generate!({
world: "tenant-flight",
path: "wit",
additional_derives: [
serde::Deserialize,
serde::Serialize,
],
generate_all,
});
mod booking;
mod search;
struct Component;
// Implement the exported `contracts` interface. Each generated method unwraps
// the input bytes and hands off to the module that does the work.
#[cfg(target_arch = "wasm32")]
impl exports::z::tenant_flight::contracts::Guest for Component {
fn search_offers(req: exports::z::tenant_flight::contracts::GenericInput) -> Result, String> {
let input = req.input.ok_or("search-offers: missing input")?;
search::search_offers(&input)
}
fn book_offer(req: exports::z::tenant_flight::contracts::GenericInput) -> Result, String> {
let input = req.input.ok_or("book-offer: missing input")?;
booking::book_offer(&input)
}
}
#[cfg(target_arch = "wasm32")]
export!(Component);
```
The host bindings live under `crate::host::*` and the exported interface under `crate::exports::*` — both generated by the macro from `wit/`.
### search.rs — `search_offers` (synchronous `http`, no PII)
The `http` interface is synchronous: the response is available before the call returns. Build a `Request` with a `Verb`, headers, and an optional payload.
```rust theme={null}
use crate::host::interfaces::{http as http_iface, logging};
let resp = http_iface::call(&http_iface::Request {
method: http_iface::Verb::Post,
url: format!("{DUFFEL_BASE}/air/offer_requests?return_offers=false"),
headers: Some(duffel_headers(&api_key)), // Vec<(String, String)>
payload: Some(serde_json::to_vec(&offer_request_body).map_err(|e| e.to_string())?),
})
.map_err(|e| format!("duffel offer-request: {e}"))?;
if resp.code != 201 {
let body = String::from_utf8_lossy(&resp.payload);
return Err(format!("Duffel offer-request failed: HTTP {} — {body}", resp.code));
}
let _ = logging::info("offer request created");
// resp.payload holds the response bytes — parse with serde_json.
```
[Outbound HTTP is authorized by the user, not the contract](/developers/adk/tips/outbound-http-auth-by-user) — the hosts a contract may reach are resolved per-call from the calling user's grant.
### booking.rs — `book_offer` (PII via `http-with-placeholders`)
For calls that carry user PII, use `http-with-placeholders`. Put `{{profile.}}` markers in the request body; the host resolves them from the calling user's profile at dispatch time, so plaintext PII never enters WASM memory.
```rust theme={null}
use crate::host::interfaces::http_with_placeholders as hwp;
use serde_json::json;
let order_body = json!({
"data": {
"type": "instant",
"selected_offers": [req.offer_id],
"passengers": [{
"id": req.passenger_id, // opaque Duffel id — not PII
// Resolved host-side from the user's profile (PII never enters WASM):
"given_name": "{{profile.first_name}}",
"family_name": "{{profile.last_name}}",
"born_on": "{{profile.date_of_birth}}",
"email": "{{profile.verified_contacts.email.value}}",
}],
"payments": [{ "type": "balance", "amount": req.total_amount, "currency": req.total_currency }]
}
});
let resp = hwp::call(&hwp::Request {
method: hwp::Verb::Post,
url: format!("{DUFFEL_BASE}/air/orders"),
headers: Some(duffel_headers(&api_key)),
payload: Some(serde_json::to_vec(&order_body).map_err(|e| e.to_string())?),
})
.map_err(|e| format!("duffel create-order: {}", format_http_error(e)))?;
```
`hwp::call` returns a typed `HttpError` so failures never leak resolved PII — match on it for clear messages:
```rust theme={null}
fn format_http_error(e: hwp::HttpError) -> String {
match e {
hwp::HttpError::EgressDenied(host) => format!("egress denied for host {host}"),
hwp::HttpError::PlaceholderDenied(marker) => format!("placeholder not permitted: {marker}"),
hwp::HttpError::PlaceholderUnknown(field) => format!("user profile missing field: {field}"),
hwp::HttpError::PlaceholderNoUserContext => "no user context bound for placeholder resolution".to_string(),
hwp::HttpError::UpstreamError(reason) => format!("upstream: {reason}"),
}
}
```
See [Placeholders in outbound calls](/developers/adk/tips/placeholders-outbound-calls).
### Reading secrets from the `secrets` KV map
The API key is read from the tenant's `secrets` KV map at runtime. [The key is seeded by the tenant SDK](/developers/adk/tips/seed-api-key) before the contract runs — there is no `set-credentials` host function. `kv-store` calls take the **full** `z::