Protocol / Applications / Example: Listen
shippedLayer 2 · App

Example: Listen

Listen is the canonical Layer-2 TinyCloud app — app_id xyz.tinycloud.listen, defaults true — storing SQL conversations and KV transcript blobs in the applications space, operated by a TEE backend through one materialized UCAN delegation.

Example: Listen

Listen is the canonical Layer-2 TinyCloud app: a transcript workspace that syncs meeting transcripts (Fireflies, Granola, Google Meet) into the owner's own spaces and lets a TEE backend operate on that data through a single UCAN delegation — without the backend ever holding the owner's key or seeing secret plaintext. It is the worked example every other applications concept points to, because it exercises the whole stack: a manifest, the applications space, KV + SQL, one-signature capability composition, a delegate backend, and encrypted secrets.

Role

Listen sits in Layer 2 — a manifest app built by TinyCloud that enshrines a space. It is the reference instance for manifest-model, the applications space, and tee-backends: where those concepts describe a mechanism in the abstract, Listen is the concrete app_id, the concrete tables, and the concrete delegation that make them real. The read-only Feed explorer is an L3 consumer of exactly this data.

Mechanics

The manifest

listen/manifest.json is the app/data contract (manifest-model):

{
  "manifest_version": 1,
  "app_id": "xyz.tinycloud.listen",
  "name": "Listen",
  "defaults": true,
  "secrets": {
    "FIREFLIES_API_KEY": ["read"],
    "GRANOLA_API_KEY": ["read"],
    "ASSEMBLYAI_API_KEY": ["read"],
    "DEEPGRAM_API_KEY": ["read"],
    "GOOGLE_MEET_TOKENS": { "scope": "listen", "actions": ["read","write","delete"] }
  },
  "permissions": [
    { "service": "tinycloud.hooks", "path": "sql/conversations/conversation",
      "actions": ["subscribe"], "skipPrefix": true }
  ]
}
  • app_id: xyz.tinycloud.listen is the stable namespace and the default path prefix for everything Listen writes.
  • defaults: true requests the built-in app-scoped tier — KV + SQL read/write under the xyz.tinycloud.listen/ prefix — in addition to the one explicit permissions[] entry.
  • No space field, so app data defaults to the applications space (tinycloud:{did-suffix}:applications/…). No top-level did, so the manifest itself is not a delegation target; the app session key is the actor and the TEE backend is added as a delegate at compose time.
  • secrets{} declares vault entries; the SDK composer maps each to a tinycloud.kv/get grant on vault/secrets/<NAME> in the distinct secrets space.
  • The lone permissions[] entry is a tinycloud.hooks/subscribe cap (with skipPrefix: true, an absolute service-namespace path) for live conversation-row write events.

Where the data lives

All app data is in the owner's applications space, keyed under xyz.tinycloud.listen:

DataService / pathShape
ConversationsSQL db xyz.tinycloud.listen/conversationsconversation, participant tables
TranscriptsKV xyz.tinycloud.listen/transcript/<conversationId>TranscriptSentence[] JSON blob

The SQL schema is PRIMARY KEY-only — TinyCloud's SQLite authorizer forbids CREATE INDEX, UNIQUE, and REFERENCES, so dedup is done in app code (backend/src/schema.ts):

CREATE TABLE IF NOT EXISTS conversation (
  id TEXT PRIMARY KEY, title TEXT, source TEXT NOT NULL, source_id TEXT,
  source_url TEXT, started_at TEXT, ended_at TEXT, duration_secs REAL,
  summary TEXT, metadata TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS participant (
  id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, name TEXT NOT NULL,
  email TEXT, speaker_label TEXT
);

persistConversation() writes the conversation row, the participant rows, and then the transcript KV blob (backend/src/services/persist-conversation.ts) at key xyz.tinycloud.listen/transcript/<conversationId>.

The backend is a delegate, never a key-holder

Listen's backend is the canonical TEE backend. It advertises a DID plus the permissions it wants over /api/server-info (backendDelegationPermissions() in backend/src/manifest.ts): KV get/put/del/list/metadata under the app prefix, SQL read/write on conversations, a tinycloud.kv/get on each vault/secrets/<NAME> in the secrets space, and tinycloud.encryption/decrypt on the owner's default encryption network urn:tinycloud:encryption:<ownerDid>:default.

The frontend folds those into the manifest as a backend delegate (same app_id, with a did, defaults: false) and composes app + backend into one capability request the user signs once. After sign-in the SDK materializes the backend's UCAN from the existing session — tcw.materializeDelegation(backendDID, capabilityRequest) — with no second wallet prompt (packages/client/src/delegation.ts), then POSTs the serialized PortableDelegation to /api/delegations. The backend activates it against the node via node.useDelegation(...) and reads secrets only by asking the node to decrypt under the delegated network grant (backend/src/delegation-activation.ts) — so it never sees secret plaintext and never holds the owner's root key. It also self-checks that its requested caps are a subset of what the manifest grants (isCapabilitySubset), which is the subset rule in action.

How it was built

Listen is the frontend + backend shape, and it follows the exact path a new app follows with Getting Started and tinyboilerplate:

  1. Manifest — author manifest.json (manifest-model) declaring app_id: xyz.tinycloud.listen, the defaults: true tier, the secrets{} block, and the one tinycloud.hooks/subscribe permission.
  2. Composition — the frontend fetches the backend's advertised policy from /api/server-info, turns it into a backend delegate manifest (same app_id, a did, defaults: false), and composes [appManifest, backendManifest] into one capability request.
  3. Sign-in — the user signs that composed SIWE/ReCap once (sign-in-flow); the session key now holds the whole union and the backend did is pre-authorized.
  4. Delegation — the frontend materializes the backend's PortableDelegation from the session key with no second prompt (materializeDelegation) and POSTs it to /api/delegations; the backend activates it with node.useDelegation(...).
  5. Backend persistence — using only the delegated capability, the TEE backend writes the conversation/participant rows to SQL and the transcript blob to KV in the owner's applications space, decrypting secrets through the node.

The difference between Listen and the blank starter is only the manifest, the data model, and the backend routes — the compose → sign-once → materialize → operate spine is identical.

Shape

The on-disk contract is manifest.json above. The runtime identity is app_id = xyz.tinycloud.listen; the owner DID is did:pkh:eip155:1:<address>; the addressable data is tinycloud:pkh:eip155:1:<addr>:applications/{sql|kv}/xyz.tinycloud.listen/… (see uri-addressing-grammar).

Relationships

Built from a manifest; enshrines the applications space; stores via SQL + KV; authorized through composed capabilities and one delegation; operated by a TEE backend; reads secrets from the secrets space decrypted on an encryption network; registered in the install registry in the account space; read downstream by Feed. The full source trace is the apps map.

Example

A user signs in to Listen with their wallet (OpenKey) once. That single SIWE/ReCap signature authorizes the app session key and materializes a UCAN for the TEE backend. Fireflies syncs a meeting: the backend writes a conversation row + participant rows to SQL xyz.tinycloud.listen/conversations and the transcript JSON to KV xyz.tinycloud.listen/transcript/<id>, all in the owner's applications space, after decrypting FIREFLIES_API_KEY from secrets/vault/secrets/FIREFLIES_API_KEY through the node. Later, Feed self-grants read caps as the owner and queries the same rows over the tc CLI — same data, different identity, all under the owner's capabilities.

Status & drift

Shipped. Two source-level discrepancies are documented in the apps map (not resolved here): the listen-importer writes without --space, so importer-published conversations may land in default rather than applications; and two transcript KV path conventions (transcript/<id> vs importer/transcripts) coexist. Treat the backend-written paths above (persist-conversation.ts) as canonical for Listen-app data.

Sources

  • listen: manifest.json (the contract above), SPEC-manifest-and-capability-chain.md (manifest semantics, applications default, compose→materialize), backend/src/schema.ts (SQL schema + conversations db routing), backend/src/services/persist-conversation.ts (transcript KV path + write pass), backend/src/manifest.ts (backendDelegationPermissions, isCapabilitySubset), backend/src/delegation-activation.ts (node.useDelegation, secrets-via-node decrypt), packages/client/src/delegation.ts (materializeDelegation, prompted)
  • Map: maps/apps-feed-listen.md