tinkaria
Advanced / operator

Advanced: ship a userland app (worker + dist by hash)

Build, publish, and use a realtime custom-worker app on tinkaria over NATS — no operator, no redeploy. Upload artifacts by content hash, then author a descriptor that points at them.

This page teaches you to ship a userland app end to end: a custom backend worker (server-side compute) and a custom dist (browser bundle), delivered to production by content hash over NATS — no operator action, no image rebuild, no redeploy. You upload each artifact, get back its sha256 id, then author one JSON descriptor that references those ids. The platform adopts it live.

This is distinct from Author a platform app, which describes an app served by the platform's built-in static surface (no custom code). Use this page when you want your own server-side logic and/or your own frontend bundle.

Everything below travels over NATS (the sole channel). HTTP is used only to log in and to vend short-lived NATS credentials — it never mutates app state.

The browser half of your app is a shell view. The platform serves a credential-holding tinkaview shell as the top document; your dist runs inside it as a sandboxed, null-origin <iframe> view that talks to the shell over postMessage. Your view holds no credential, imports no nats.ws, fetches no /config, and has no reconnect / re-vend / expiry code — the platform shell owns the credential and the single NATS connection. You write a dumb renderer; the platform owns the wire.

You do not need an operator to provision a namespace. The first descriptor you author into an unclaimed namespace auto-claims it to your account and provisions its NATS account (with a default JetStream quota so artifact upload works). Pick a fresh namespace name and it is yours.

The shape of the system

For a backend-backed app named <ns>/<app>:

  • Everyone (worker + every browser) collaborates inside one session subtree: app.<app>.session.main.> (the session id for a backend app is always the literal main).
  • The worker subscribes app.<app>.session.main.input.> and publishes state on app.<app>.session.main.view.<region> (use region main).
  • The platform shell (which holds the credential) subscribes app.<app>.session.main.view.> and publishes inputs on app.<app>.session.main.input.<verb>, relaying each to/from your view as postMessage. You never name a NATS subject — the shell owns every subject, inside the session grant ceiling.

The worker holds no credential: it reaches NATS only through a local bridge socket that tinkaria injects. Your view holds no credential either: the platform shell holds the short-lived anonymous credential it vends at /<ns>/<app>/config, owns the single NATS connection, and re-vends the credential before it expires — your view never touches it.

The view contract (postMessage)

The platform serves a tinkaview shell as the top document. That shell (the same one that backs the platform's own adminconsole/homeapps) holds the anonymous credential, owns the single NATS connection, and re-vends the credential before it expires. It hosts your dist inside a sandboxed, null-origin <iframe> VIEW (sandbox="allow-scripts", deliberately no allow-same-origin). State crosses the iframe boundary only as postMessage.

Your view talks to the shell with exactly these four messages (the shipped contract — read the shell runtime internal/tinkaview/assets/tinkaview.js and the reference view frontends/harness/ui/tinkaview/transport.ts):

DirectionMessage (event.data)Meaning
shell → view{ type: "tinkaview:render", region, payload }a view.<region> update. payload is the string the worker published on app.<app>.session.main.view.<region> — parse it yourself (e.g. JSON.parse).
shell → view{ type: "tinkaview:status", status }connection-status string ("connecting…", "live", an error). Optional to display.
view → shell{ type: "tinkaview:input", event, payload }the shell publishes this on app.<app>.session.main.input.<event> with body String(payload).
view → shell{ type: "tinkaview:ready" }post on mount → the shell re-sends the latest render for every region (re-prime). Post it last, after your message listener is attached, or you miss the re-prime and the view stays blank.
  • The shell subscribes app.<app>.session.main.view.> and maps each region into a tinkaview:render; it publishes each tinkaview:input on app.<app>.session.main.input.<event>. You never name a NATS subject — the shell owns every subject, inside the session grant ceiling.
  • payload is always a string, both ways (it is the raw NATS message body). Encode structured state as JSON in the worker and JSON.parse it in the view; send structured input as a JSON.stringify'd string.

The worker is server-authoritative and unchanged by the view model: it subscribes app.<app>.session.main.input.<verb> and publishes app.<app>.session.main.view.<region>. The shell announces input.connect once when it connects, so a worker that re-sends its current state on any input hydrates a late joiner with no extra worker code.

The rest of this page ships the reference app — a shared counter — end to end: Step 0 builds the worker (0a) and the view dist (0b); Steps 1–4 log in, upload, author, and reach it.

Step 0 — build the two artifacts FIRST

Ordering is load-bearing. Upload BOTH artifacts and record their sha256 ids BEFORE you author the descriptor. If you author a descriptor that references an artifact id that is not uploaded yet, the backend reconcile fails "artifact not found" and stays broken until the next descriptor change. Always: upload → get ids → author.

0a. The worker binary (a statically-linked Linux ELF)

The worker artifact format is a single statically-linked Linux ELF (CGO_ENABLED=0). The child runs credential-less: it dials the bridge named by TINKALET_BRIDGE_SOCKET, authenticates with TINKALET_BRIDGE_TOKEN, and derives its subtree root from TINKARIA_BACKEND_APP + TINKARIA_BACKEND_SESSION. The bridge speaks a tiny newline-delimited JSON protocol; because platform internals are not importable from your module, the worker below includes a complete ~40-line bridge client of its own.

This example is a shared counter: any input.tap increments it; the new count is pushed to every viewer.

worker/go.mod:

module counterworker

go 1.23

worker/main.go:

package main

import (
	"bufio"
	"encoding/base64"
	"encoding/json"
	"net"
	"os"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
)

// ---- minimal tinkalet bridge client (newline-delimited JSON frames) ----
// The bridge accepts client->server frames {op,id,sid,subject,data} and emits
// server->client frames {type,id,sid,subject,data,error}. `data` is base64.
// The MANDATORY first frame is op="hello" carrying the per-child token.

type frame struct {
	Op      string `json:"op,omitempty"`
	Type    string `json:"type,omitempty"`
	ID      string `json:"id,omitempty"`
	SID     string `json:"sid,omitempty"`
	Subject string `json:"subject,omitempty"`
	Data    string `json:"data,omitempty"` // base64
	Token   string `json:"token,omitempty"`
	Error   string `json:"error,omitempty"`
}

type bridge struct {
	conn net.Conn
	mu   sync.Mutex // serialises writes
	seq  atomic.Uint64
	subs sync.Map // sid -> func(subject string, data []byte)
	acks sync.Map // id  -> chan frame
}

func dial(sock, token string) (*bridge, error) {
	c, err := net.Dial("unix", sock)
	if err != nil {
		return nil, err
	}
	b := &bridge{conn: c}
	go b.readLoop()
	// Handshake: send hello, wait for its ack.
	_, err = b.roundtrip(frame{Op: "hello", Token: token})
	return b, err
}

func (b *bridge) readLoop() {
	sc := bufio.NewScanner(b.conn)
	sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
	for sc.Scan() {
		var f frame
		if json.Unmarshal(sc.Bytes(), &f) != nil {
			continue
		}
		if f.Type == "msg" {
			if h, ok := b.subs.Load(f.SID); ok {
				data, _ := base64.StdEncoding.DecodeString(f.Data)
				h.(func(string, []byte))(f.Subject, data)
			}
			continue
		}
		if ch, ok := b.acks.LoadAndDelete(f.ID); ok {
			ch.(chan frame) <- f
		}
	}
}

func (b *bridge) write(f frame) error {
	buf, _ := json.Marshal(f)
	b.mu.Lock()
	defer b.mu.Unlock()
	if _, err := b.conn.Write(append(buf, '\n')); err != nil {
		return err
	}
	return nil
}

func (b *bridge) roundtrip(f frame) (frame, error) {
	id := strconv.FormatUint(b.seq.Add(1), 10)
	f.ID = id
	ch := make(chan frame, 1)
	b.acks.Store(id, ch)
	if err := b.write(f); err != nil {
		return frame{}, err
	}
	return <-ch, nil
}

// PublishNoAck fires a publish WITHOUT waiting for an ack. This is REQUIRED when
// publishing from inside a subscription handler: the read loop dispatches handlers
// inline, so a blocking publish-ack would deadlock the loop until timeout.
func (b *bridge) PublishNoAck(subject string, data []byte) {
	_ = b.write(frame{Op: "pub", Subject: subject, Data: base64.StdEncoding.EncodeToString(data)})
}

func (b *bridge) Subscribe(subject string, h func(subject string, data []byte)) error {
	sid := "s" + strconv.FormatUint(b.seq.Add(1), 10)
	b.subs.Store(sid, h)
	resp, err := b.roundtrip(frame{Op: "sub", SID: sid, Subject: subject})
	if err != nil {
		return err
	}
	if resp.Error != "" {
		return &strErr{resp.Error}
	}
	return nil
}

type strErr struct{ s string }

func (e *strErr) Error() string { return e.s }

// ---- the app: a shared counter ----

func main() {
	sock := os.Getenv("TINKALET_BRIDGE_SOCKET")
	token := os.Getenv("TINKALET_BRIDGE_TOKEN")
	app := os.Getenv("TINKARIA_BACKEND_APP")
	session := os.Getenv("TINKARIA_BACKEND_SESSION") // "main"
	root := "app." + app + ".session." + session

	b, err := dial(sock, token)
	if err != nil {
		panic(err)
	}

	var count atomic.Int64

	publish := func() {
		state, _ := json.Marshal(map[string]any{"count": count.Load()})
		b.PublishNoAck(root+".view.main", state)
	}

	// Handle inputs. Subject is root+".input.<verb>".
	err = b.Subscribe(root+".input.>", func(subject string, data []byte) {
		verb := strings.TrimPrefix(subject, root+".input.")
		switch verb {
		case "tap":
			count.Add(1) // "connect" changes nothing — a joiner just needs the current state
		}
		// publish() runs for EVERY input: "tap" projects the new count, "connect"
		// re-sends current state so a late joiner isn't blank. Fire-and-forget —
		// never block on an ack inside a handler (see Pitfall 1).
		publish()
	})
	if err != nil {
		panic(err)
	}

	publish()      // initial state
	select {}      // run forever; tinkaria supervises + restarts on exit
}

Build it (the id is the sha256 of the binary):

cd worker
CGO_ENABLED=0 GOFLAGS=-mod=mod go build -trimpath -buildvcs=false -o worker.bin .
file worker.bin        # must say: ELF ... statically linked
sha256sum worker.bin   # <- WORKER_ID (record this)

0b. The view dist (a .tar.gz with index.html at the archive ROOT)

The dist artifact is a gzipped tar whose index.html sits at the archive root (not inside a subdirectory). It is your view: a dumb postMessage renderer with no nats.ws, no /config, and no connection code — the shell owns all of that. Build it with any tool; below is a minimal Vite app. Set base: "./" so asset URLs are relative (the view is served under /<ns>/<app>/view/).

web/package.json (note: no nats.ws dependency — the view imports nothing):

{
  "name": "counter-view",
  "private": true,
  "type": "module",
  "scripts": { "build": "vite build" },
  "devDependencies": { "vite": "^5.4.0" }
}

web/vite.config.js:

import { defineConfig } from "vite";
export default defineConfig({ base: "./" });

web/index.html:

<!doctype html>
<meta charset="utf-8" />
<title>counter</title>
<div id="count">…</div>
<button id="tap">tap</button>
<script type="module" src="/view.js"></script>

web/view.js — this renders the shared count and taps it. It is the whole frontend; there is no NATS code because the view has nothing to connect to:

// A tinkaview SHELL VIEW: a dumb renderer. It holds NO credential, imports NO
// nats.ws, fetches NO /config, and has NO reconnect/re-vend/expiry code. The shell
// (parent) owns the credential and the single NATS connection; this view only renders
// what the shell posts in, and posts input back out.

const countEl = document.getElementById("count");

// RENDER: the shell forwards every view.<region> update as a window 'message'.
// event.data = { type:"tinkaview:render", region, payload }. `payload` is the STRING
// the worker published on app.<app>.session.main.view.<region>; parse it yourself.
window.addEventListener("message", (ev) => {
  const msg = ev.data || {};
  if (msg.type === "tinkaview:render" && msg.region === "main") {
    const state = JSON.parse(msg.payload); // the worker published {"count": N}
    countEl.textContent = state.count;
  }
  // msg.type === "tinkaview:status" carries the connection-status string; ignore it
  // unless you want to show "connecting…" / "live".
});

// INPUT: post to the parent shell, which publishes it on
// app.<app>.session.main.input.<event> with body String(payload).
const send = (event, payload = "") =>
  window.parent.postMessage({ type: "tinkaview:input", event, payload }, "*");

document.getElementById("tap").onclick = () => send("tap");

// READY: announce mount so the shell re-primes us with the current state (it re-sends
// the latest render for every region on "ready"). Post it LAST — after the message
// listener above exists — or the re-prime is missed and the view stays blank.
window.parent.postMessage({ type: "tinkaview:ready" }, "*");

The view sandbox has no allow-forms — an HTML <form> submit is BLOCKED. The view runs at sandbox="allow-scripts" (null origin) with no allow-forms, so submitting a <form> silently does nothing. Wire inputs with a button click and an Enter keydown handler instead of a form submit:

const textEl = document.getElementById("text");
const add = () => { send("add", textEl.value); textEl.value = ""; };
document.getElementById("add").onclick = add;
textEl.addEventListener("keydown", (e) => { if (e.key === "Enter") add(); });

Build and pack (index.html at the archive root):

cd web
npm install
npm run build            # emits dist/
tar -C dist -czf dist.tar.gz .   # index.html is now at the tar ROOT
sha256sum dist.tar.gz    # <- DIST_ID (record this)

Step 1 — create an account and log in

Both credential vends below are cookie-authenticated. Register once, then log in to set the tinka_home cookie.

BASE=https://your-tinkaria-host        # the platform origin
# Register (idempotent handle; 409 if taken).
curl -sX POST "$BASE/register" -H 'content-type: application/json' \
  -d '{"handle":"alice","secret":"correct horse battery staple"}'
# Log in — save the cookie jar.
curl -sX POST "$BASE/login" -c cookies.txt -H 'content-type: application/json' \
  -d '{"handle":"alice","secret":"correct horse battery staple"}'

Every subsequent vend must send that cookie (-b cookies.txt). Your owner id is your handle (alice).

Step 2 — upload the two artifacts over NATS

Get an upload credential (cookie-auth), then run the begin/chunk/commit conversation. The credential is confined to your own owner subtree, and the responder gates the target namespace live against its current owner — so you can only upload into a namespace you already own.

Brand-new namespace? Claim it first. Ownership is established by the first descriptor .put (Step 3), not by upload. So if your namespace has never been claimed, the very first upload is rejected with not authorized to upload into this namespace. When that happens, follow this exact order:

  1. Author a placeholder descriptor now (Step 3's request, but backend.type: "static" and no artifactRef/promotion — a static app owns nothing to fetch). This auto-claims the namespace to you and provisions its NATS account.
  2. Come back and upload both artifacts (below) — now accepted.
  3. Author the real descriptor (Step 3) referencing the uploaded hashes.

The placeholder descriptor JSON:

{
  "namespace": "myns",
  "appId": "counter",
  "displayName": "Counter (claiming)",
  "authMode": "anonymous",
  "backend": { "type": "static" }
}

If the namespace is already yours, ignore this and use the normal order: upload → get hashes → author.

curl -sX POST "$BASE/apps/upload-cred" -b cookies.txt > upcred.json
# upcred.json = {wss, user_jwt, seed, inbox_prefix, subject_prefix, owner, ...}
# subject_prefix = tenant.artifact-upload.session.<owner>.artifact

Upload each file with this Node script (upload.mjs). It reproduces the exact protocol:

  • begin — request on <subject_prefix>.begin with body {"namespace":"<ns>"} → reply {upload_id, chunk_size}.
  • chunk — for each slice of chunk_size bytes (default 524288), request on <subject_prefix>.chunk with the raw bytes as the body and two headers: Upload-Id: <upload_id> and Seq: <0-based index> → reply {seq}.
  • commit — request on <subject_prefix>.commit with body {"upload_id":..., "sha256":<hex of the whole file>, "total_chunks":<count>} → reply {artifact_id} (equals your sha256).
// upload.mjs — usage: node upload.mjs <cred.json> <namespace> <file>
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { connect, jwtAuthenticator, StringCodec, headers } from "nats.ws";

const [, , credPath, ns, file] = process.argv;
const cred = JSON.parse(readFileSync(credPath, "utf8"));
const bytes = readFileSync(file);
const sc = StringCodec();
const sha256 = createHash("sha256").update(bytes).digest("hex");

const nc = await connect({
  servers: cred.wss,
  authenticator: jwtAuthenticator(cred.user_jwt, new TextEncoder().encode(cred.seed)),
  inboxPrefix: cred.inbox_prefix,
});
const P = cred.subject_prefix; // tenant.artifact-upload.session.<owner>.artifact

// begin
const begin = JSON.parse(
  sc.decode((await nc.request(`${P}.begin`, sc.encode(JSON.stringify({ namespace: ns })), { timeout: 30000 })).data),
);
if (begin.error) throw new Error(`begin: ${begin.error}`);
const chunkSize = begin.chunk_size || 524288;

// chunk*
let seq = 0;
for (let off = 0; off < bytes.length; off += chunkSize, seq++) {
  const h = headers();
  h.set("Upload-Id", begin.upload_id);
  h.set("Seq", String(seq));
  const slice = bytes.subarray(off, Math.min(off + chunkSize, bytes.length));
  const ack = JSON.parse(sc.decode((await nc.request(`${P}.chunk`, slice, { headers: h, timeout: 30000 })).data));
  if (ack.error) throw new Error(`chunk ${seq}: ${ack.error}`);
}

// commit
const commit = JSON.parse(
  sc.decode(
    (await nc.request(`${P}.commit`, sc.encode(JSON.stringify({ upload_id: begin.upload_id, sha256, total_chunks: seq })), { timeout: 30000 })).data,
  ),
);
if (commit.error) throw new Error(`commit: ${commit.error}`);
console.log(commit.artifact_id); // == sha256
await nc.close();

Run it for both artifacts (record each printed id — they equal the sha256 you computed):

npm i nats.ws
WORKER_ID=$(node upload.mjs upcred.json myns worker/worker.bin)
DIST_ID=$(node upload.mjs upcred.json myns web/dist.tar.gz)

(If this is a brand-new namespace and the upload was refused, do the placeholder-claim first — see the warning at the top of this step.)

Step 3 — author the descriptor over NATS

Get an author credential (cookie-auth) and publish the descriptor. Only now — after both ids exist — do you reference them.

curl -sX POST "$BASE/apps/author-cred" -b cookies.txt > authcred.json
# authcred.json = {wss, user_jwt, seed, inbox_prefix, subject_prefix, owner, ...}
# subject_prefix = tenant.appreg-write.session.<owner>.appreg

The descriptor JSON (validated field names):

{
  "namespace": "myns",
  "appId": "counter",
  "displayName": "Shared Counter",
  "authMode": "anonymous",
  "backend": { "type": "bwrap", "artifactRef": "<WORKER_ID>" },
  "promotion": { "distRef": "<DIST_ID>" }
}

A descriptor that names both a backend worker and a promotion dist is served as a shell view by default — the platform serves the credential-holding shell as the top document and hosts your dist as the null-origin view. You do not set any viewMode field; shell delivery is the default for a worker + dist app.

Field rules that will bite you if wrong:

  • authMode must be one of anonymous, all-members, invited, namespace-only. Use anonymous for an open app.
  • backend.artifactRef and promotion.distRef must each be a lowercase-hex sha256 (64 chars). They are mutually exclusive with their operator-slug siblings: do not also set backend.runner or promotion.distSlug.
  • backend.type for a custom worker is bwrap (the sandboxed exec-by-hash runner).
  • namespace/appId are literal subject tokens ([a-z0-9-]-ish); no dots, no slashes.

Publish it with a request on <subject_prefix>.put, body {"descriptor": <the JSON above>}. Reply is {"ok":true,"hash":"<content hash>"} or {"ok":false,"reason":"<why>"}.

// author.mjs — usage: node author.mjs <cred.json> <descriptor.json>
import { readFileSync } from "node:fs";
import { connect, jwtAuthenticator, StringCodec } from "nats.ws";

const [, , credPath, descPath] = process.argv;
const cred = JSON.parse(readFileSync(credPath, "utf8"));
const descriptor = JSON.parse(readFileSync(descPath, "utf8"));
const sc = StringCodec();

const nc = await connect({
  servers: cred.wss,
  authenticator: jwtAuthenticator(cred.user_jwt, new TextEncoder().encode(cred.seed)),
  inboxPrefix: cred.inbox_prefix,
});
const reply = JSON.parse(
  sc.decode(
    (await nc.request(`${cred.subject_prefix}.put`, sc.encode(JSON.stringify({ descriptor })), { timeout: 30000 })).data,
  ),
);
console.log(reply); // { ok: true, hash: ... }  OR  { ok: false, reason: ... }
await nc.close();
if (!reply.ok) process.exit(1);
node author.mjs authcred.json descriptor.json

The platform adopts the descriptor live (zero restart): it fetches the worker by hash into a sandbox, starts it, and serves the dist by hash as your view. Other verbs on the same subtree: <subject_prefix>.delete (body {"namespace","app"}) and <subject_prefix>.list (body {"namespace"}{ok,keys}).

Step 4 — use it

Open https://your-tinkaria-host/myns/counter/ in a browser. The platform serves the credential-holding shell, which hosts your view at /myns/counter/view/, holds the anonymous credential, owns the single NATS connection, and drives your view over NATS. It renders the count, and each tap increments the shared counter for every connected viewer in realtime. Open a second tab to see both update together.

  • GET /myns/counter/ → the tinkaview shell (holds the credential + the NATS connection)
  • GET /myns/counter/view/ → your dist's index.html, hosted as the null-origin sandboxed view
  • GET /myns/counter/config → the anonymous session credential the shell fetches (your view never touches it) {wss, namespace, app, session, user_jwt, seed, inbox_prefix, dist}

To update the app, upload new artifact(s) and re-author the descriptor with the new id(s). Open tabs pick up the new dist automatically — the shell owns the reconnect and reload.

Reactive patterns from your worker

You can build reaction-like behavior in your own worker without registering a platform chain reaction. Watch your app's KV through the bridge SDK (KVWatch), compute from each delivered update, then write results back with KVPut: watch → compute → write.

That loop is your worker's responsibility. After a reconnect or worker restart, a watch can hydrate or re-deliver state, so make the writes idempotent (for example, derive output keys from the input key/revision, or compare the current output before writing). The same durable-state gate applies: if the host has not enabled bridge KV, watch/write calls are denied, so fall back to an ephemeral path instead of crashing.

Durable state (survives a worker restart)

The counter above is in-memory: re-authoring the descriptor (or any restart) resets it to 0, and a worker that crashed forgets everything. Durable per-app state fixes that. It is a small key/value store scoped to your one app: values you kvput survive a worker restart, and on boot the worker kvgets them back (re-hydration), so a late joiner reads the current value instead of a blank.

How the confinement works (read this — it is the security contract). The worker still holds no KV/JetStream credential. It speaks KV ops over the same bridge socket it already uses for pub/sub; the bridge server — which alone holds the state-bucket connection — routes every op to the platform state authority under this app's own (namespace, app), derived from the worker's identity. The worker never puts a namespace, app, or bucket name on the wire — only a key. So a worker can address only its own app's state and structurally cannot reach another app's or another tenant's state. (The authority additionally re-checks the namespace's current owner on every single op and fails closed on a reclaim, so state never leaks across an ownership change.)

Durable state is operator-gated. An app gets durable KV only when the host is started with TINKAHOST_STATE_ENABLE=1 (like namespace provisioning, this is an operator switch). On a host without it, the bridge serves no state and every KV op is denied with an ack error: bridge denied kv-put: this bridge serves no durable state. Design an opt-in app to degrade gracefully when a kvput/kvget returns that error (fall back to ephemeral in-memory state) rather than crash.

Key rules (a kvput with a bad key is denied)

  • A key must be non-empty and a legal NATS KV key: only the characters a–z A–Z 0–9 - / _ = . (any other character — space, :, *, etc. — is rejected).
  • A key must not start with $ (the reserved $-system space) or with _tinkr_ (the store's own reserved bookkeeping prefix, where the owner fingerprint lives). Both are denied unconditionally — a kvput/kvget/kvdel on such a key returns an ack error statestore: reserved or invalid state key.
  • kvlist never returns the store's reserved keys — you see only your own keys.

The KV wire ops (extend the Step 0a bridge client)

The KV ops reuse the exact same frame protocol as pub/sub — a client→server frame carrying an op, and a server→client ack (correlated by id) carrying data and/or error. data is base64 (same as pub/sub payloads). Add these five ops to the frame struct and client from Step 0a:

Op (op)Client sendsServer replies (type:"ack", same id)Meaning
kvget{op:"kvget", id, key}{type:"ack", id, data} — or {…, error} if absent/deniedread this app's key
kvput{op:"kvput", id, key, data}{type:"ack", id} — or {…, error}durably store data under key
kvdel{op:"kvdel", id, key}{type:"ack", id} (deleting a missing key is a no-op)delete key
kvlist{op:"kvlist", id}{type:"ack", id, data}data is a JSON array of key stringslist this app's keys
kvwatch{op:"kvwatch", id, sid}{type:"ack", id}, then a stream of kvupdate framessubscribe to live state changes
kvunwatch{op:"kvunwatch", sid}(no ack)stop a watch

For kvwatch, after the ack the server pushes type:"kvupdate" frames on your sid: {type:"kvupdate", sid, key, data, rev, deleted, init}. The initial hydration snapshot (current state for a late joiner) arrives as a run of frames with init:true, then live changes arrive with init:false; deleted:true marks a removed key; rev is the KV revision. A terminal {type:"kvupdate", sid, final:true, error} ends the watch (empty error = clean stop; a non-empty error = failed closed, e.g. the namespace was reclaimed).

Extend the frame struct and add the two methods the counter needs (Get + Put):

// add these fields to the Step 0a `frame` struct:
//   Key     string `json:"key,omitempty"`
//   Rev     uint64 `json:"rev,omitempty"`     // on kvupdate
//   Deleted bool   `json:"deleted,omitempty"` // on kvupdate
//   Init    bool   `json:"init,omitempty"`    // on kvupdate: hydration snapshot
//   Final   bool   `json:"final,omitempty"`   // on kvupdate: terminal frame

// KVGet reads this app's durable state key. Returns (nil, err) if absent or denied.
// SAFE to call from the main goroutine (see the deadlock note below — do NOT call it
// from inside a subscription handler in this minimal inline-dispatch client).
func (b *bridge) KVGet(key string) ([]byte, error) {
	resp, err := b.roundtrip(frame{Op: "kvget", Key: key})
	if err != nil {
		return nil, err
	}
	if resp.Error != "" {
		return nil, &strErr{resp.Error}
	}
	return base64.StdEncoding.DecodeString(resp.Data)
}

// KVPut durably stores value under key for this app. Survives a worker restart.
func (b *bridge) KVPut(key string, value []byte) error {
	resp, err := b.roundtrip(frame{Op: "kvput", Key: key, Data: base64.StdEncoding.EncodeToString(value)})
	if err != nil {
		return err
	}
	if resp.Error != "" {
		return &strErr{resp.Error}
	}
	return nil
}

Same deadlock trap as Pitfall 1. In this minimal client the read loop dispatches subscription handlers inline, and KVGet/KVPut are round-trips that wait for an ack the read loop must deliver. So calling KVPut directly inside a Subscribe handler deadlocks the loop — exactly like a blocking publish-ack. Fix: hand the input off to a separate state-owner goroutine (below) that owns the count and does the blocking KV round-trips off the read path. (The platform SDK avoids this by dispatching handlers on their own goroutine; your hand-written client does not, so keep KV round-trips off the read loop.)

The durable-counter worker (replace Step 0a's main)

This is the Step 0a counter made durable: it hydrates count from KV on boot, and persists it on every tap. Restart the worker (or re-author the descriptor) and the count resumes where it left off; a late joiner reads the current value. It keeps all KV round-trips on a single state-owner goroutine (off the read loop), so there is no deadlock.

func main() {
	sock := os.Getenv("TINKALET_BRIDGE_SOCKET")
	token := os.Getenv("TINKALET_BRIDGE_TOKEN")
	app := os.Getenv("TINKARIA_BACKEND_APP")
	session := os.Getenv("TINKARIA_BACKEND_SESSION") // "main"
	root := "app." + app + ".session." + session

	b, err := dial(sock, token)
	if err != nil {
		panic(err)
	}

	// HYDRATE from durable state (main goroutine — not the read loop, so the blocking
	// round-trip is safe). Absent key -> start at 0. If durable state is operator-disabled
	// the Get returns an error; degrade to ephemeral 0 rather than crash.
	var count int64
	if v, err := b.KVGet("count"); err == nil {
		count, _ = strconv.ParseInt(string(v), 10, 64)
	}

	publish := func(c int64) {
		state, _ := json.Marshal(map[string]any{"count": c})
		b.PublishNoAck(root+".view.main", state)
	}

	// STATE-OWNER goroutine: the ONLY place that reads/mutates/persists count. Running it
	// off the read loop makes the blocking KVPut round-trip safe (see the deadlock Callout).
	inputs := make(chan string, 64)
	go func() {
		for verb := range inputs {
			if verb == "tap" {
				count++
				if err := b.KVPut("count", []byte(strconv.FormatInt(count, 10))); err != nil {
					// Durable state disabled/denied: keep the in-memory count so the app
					// still works ephemerally (it just won't survive a restart).
				}
			}
			publish(count) // "tap" projects the new count; "connect" re-sends current state
		}
	}()

	// Handle inputs. Never touch count or KV here — hand off to the state owner.
	err = b.Subscribe(root+".input.>", func(subject string, data []byte) {
		inputs <- strings.TrimPrefix(subject, root+".input.")
	})
	if err != nil {
		panic(err)
	}

	publish(count) // initial state (already hydrated from KV)
	select {}      // run forever; tinkaria supervises + restarts on exit
}

The frontend does not change — it still taps input.tap and renders view.main. Durability is entirely worker-side: the view can't tell the count is persisted, only that it no longer resets on a redeploy.

Pitfalls (each of these has bitten someone — read before shipping)

1. Blocking publish-ack inside a subscription handler → the worker stalls

Symptom: the app is "very slow to turn"; most taps do nothing (~1s stall then dropped inputs). Cause: the bridge read loop dispatches your subscription handler inline, so if the handler publishes and then waits for the ack, the loop can't read the ack — it deadlocks until timeout. Fix: publish fire-and-forget from inside a handler (the PublishNoAck in the worker above), or publish from a separate goroutine (a game-loop that owns state and ticks). Never round-trip a publish from within the read path.

2. Late joiner sees a blank screen → no retained state

Symptom: the first load of a view shows nothing until someone taps. Cause: core NATS publishes are not retained; a viewer that connects after the last view publish missed it. Fix (two parts, both required): (a) in the view, post tinkaview:ready last — after your message listener is attached — so the shell re-primes you with the latest render for every region; (b) in the worker, re-publish current state on input.connect (the shell announces input.connect once on connect), as the counter worker does — so a late joiner reprojects state the worker still holds in memory. This reprojection is orthogonal to durability: it does not help if the worker itself forgot the state (a restart). For state that must survive a worker restart, the worker persists it in durable per-app KV and re-hydrates from it on boot — see Durable state.

State can now be durable. A worker holds no $KV/$JS.API credential of its own, but it can still keep durable per-app state: it speaks KV ops over the same bridge (kvget/kvput/kvdel/kvlist/kvwatch), and the bridge server — which alone holds the state-bucket connection — mediates every op under this app's own (namespace, app). So state survives a worker restart and a late joiner reads the current value. Durable KV is operator-gated (TINKAHOST_STATE_ENABLE=1); on a host without it every KV op is denied, so an app that opts in should degrade gracefully. Full API + a durable-counter worker: Durable state.

3. Author-before-upload → "artifact not found"

Symptom: descriptor accepted but the backend never serves; logs say the artifact is missing. Cause: you referenced a sha256 that wasn't uploaded yet; the reconcile fails and won't retry until the next descriptor change. Fix: always upload both artifacts first, record the ids, then author. (See Step 0's warning.)

4. Wrong dist archive layout / absolute asset URLs

Symptom: blank page or 404 assets under /<ns>/<app>/view/. Cause: index.html nested in a subdirectory of the tarball, or Vite emitting absolute /assets/... URLs. Fix: tar -C dist -czf dist.tar.gz . so index.html is at the archive root, and set Vite base: "./".

Where each capability lives (recap)

ActionHTTP (vend only)NATS subject
Register / log inPOST /register, POST /login (sets tinka_home)
Upload credentialPOST /apps/upload-cred
Upload artifacttenant.artifact-upload.session.<owner>.artifact.{begin,chunk,commit}
Author credentialPOST /apps/author-cred
Write descriptortenant.appreg-write.session.<owner>.appreg.{put,delete,list}
Vend browser session (the shell fetches this)GET /<ns>/<app>/config
Serve the app (shell view — the default for a worker + dist app)GET /<ns>/<app>/ (credential-holding shell) + /<ns>/<app>/view/* (null-origin view)
App realtime stateapp.<app>.session.main.{view.<region>,input.<verb>}
Durable per-app statebridge KV ops kvget/kvput/kvdel/kvlist/kvwatch (operator-gated, own app only)

On this page