Last updated:
Lunora's own clients are TypeScript, but the wire protocol
is not TypeScript-bound. lunora sdk generate emits a client for eight other
languages, built from your deployment's own function surface.
lunora codegen --api-spec openrpc # writes lunora/_generated/openrpc.json
lunora sdk generate --lang python # → ./sdk/pythonThe generated directory is self-contained. It holds the hand-written transport (the wire codec, HTTP RPC, subscriptions, the shape/poke protocol) alongside a typed surface derived from your schema, so there is no Lunora package to install in the consuming project.
| Language | --lang | Needs installing |
|---|---|---|
| Python | python | nothing; standard library only |
| Go | go | nothing; standard library only |
| Java | java | nothing; JDK only |
| Kotlin | kotlin | nothing; JDK + Kotlin stdlib |
| Swift | swift | nothing; Foundation only |
| Dart | dart | nothing; dart:convert only, so every Flutter target works |
| Rust | rust | serde + serde_json, already declared in the emitted Cargo.toml |
| Ruby | ruby | dry-struct + dry-types, which the generated models require |
The live WebSocket loop is the one exception on Python: query/mutation/action and the codec are stdlib-only, but connect_and_run() needs pip install websockets.
The output is pinned to your CLI version
A vendored transport has to match the protocol vintage of the surface generated
beside it. So the transport is fetched from the git tag matching the CLI you ran
(@lunora/cli@<version>), and the copy records what it got:
{
"cliVersion": "1.0.0-alpha.159",
"ref": "@lunora/cli@1.0.0-alpha.159",
"source": "gh:anolilab/lunora/sdks/python",
"versionMatched": true
}Regenerating with a newer CLI brings a newer transport, which is how you upgrade.
If the exact tag has no transport for that language, the CLI falls back to the
release branch, says so loudly, and records versionMatched: false, so a copy is
never silently a different vintage than the surface next to it.
| Flag | For |
|---|---|
--out <dir> | Output directory (default ./sdk/<lang>) |
--spec <path> | An OpenRPC document other than lunora/_generated/openrpc.json |
--ref <tag> | Pin the transport explicitly. Never falls back; a miss is an error |
--from <dir> | Copy from a local checkout of sdks/ instead of fetching |
Wiring it into your project
The layout differs per language because each toolchain resolves differently. Point your build at the generated directory:
| Language | Wire it up with |
|---|---|
| Python | put sdk/python on sys.path; import lunora_api |
| Go | require/replace the emitted module at sdk/go |
| Ruby | $LOAD_PATH.unshift("sdk/ruby"); require "api" |
| Rust | a path dependency on sdk/rust in your Cargo.toml |
| Swift | .package(path: "sdk/swift"), product LunoraApi. SwiftPM identifies a path package by its directory name |
| Java | javac -sourcepath sdk/java |
| Kotlin | kotlinc sdk/kotlin … |
| Dart | lunora_sdk: {path: sdk/dart} in dependencies; import 'package:lunora_sdk/lunora_api.dart' |
What you get, and what you don't
Every language implements the full wire codec, the stable subscription key, RPC, live subscriptions, the shape/poke protocol and resume-across-reconnect, and every client is safe to share across threads. All eight get typed argument and result models generated from your schema.
All eight also implement the two client-side write features, ported from
@lunora/client and held to the same golden fixtures:
- Optimistic updates. Patch any number of subscribed queries before the server answers. A prediction is a LAYER, so an unrelated push re-folds it onto the new value rather than wiping it, and it is released the moment a frame carries the write's own commit cursor — not when the HTTP call returns, which races the socket. A failed write unwinds it.
- An offline mutation queue. A write issued while disconnected is held and replayed in order on reconnect, under the same idempotency key the call minted, so a write the server already committed is not applied twice. The queue is a bounded FIFO: overflow drops the OLDEST write, a stale precondition drops one before it replays, and a write queued under one identity never replays under another. Give the client a persistence adapter to survive a process restart.
Two deliberate gaps everywhere:
- No multi-tab leader election. A browser concern; there are no tabs here.
- No built-in HTTP or socket. Both are injected, which is what lets you keep your own stack — see below.
How you reach the queued write path differs in one place, and it is worth knowing before you look for a method that is not there:
- Everywhere except Dart,
submitis the queued write path andmutationis unchanged.mutationstays one direct round-trip that fails when the deployment is unreachable, because the generated typed wrappers call it.submitreturns immediately with astatusofcommittedorqueued; the eventual verdict on a queued write arrives through the client's settled callback rather than by blocking a thread. Stamp the queue's identity yourself withclient.identity— a stable, non-secret subject such as a user id. - Dart uses
mutationdirectly and learns about connectivity from you. It does not own a socket, sosetConnected(true|false)is what flushes the queue, and itsLunoraPersistenceadapter is asynchronous where the other seven's is synchronous.
Two things only Dart gets, because a mobile client is disconnected routinely rather than exceptionally:
- A live query is a
Stream.watchList(args)(andclient.watch(path, args)) hands back aStreama FlutterStreamBuilderconsumes directly. It subscribes on first listen and unsubscribes when the last listener cancels, so disposing the widget disposes the subscription. The callback-shapedsubscribeList(...)every other language has is there too. - Batched replay. A reconnect flushing two or more writes coalesces them into
/_lunora/rpc-batchround trips instead of one request per write. The other seven replay sequentially.
One thing only Dart lacks: a per-shard drain. The other seven own a socket per shard, so one shard reconnecting flushes just its writes; Dart has a single connectivity signal, so one reconnect drains everything.
In Dart, that rides the generated mutation:
await api.messages.send(
MessagesSendArgs(channelId: 'c1', text: 'hello', kind: Kind.TEXT, tags: {}),
optimisticUpdate: (store, _) => store.setQuery(
'messages:list',
[...(store.getQuery('messages:list', args: listArgs)! as List), pending],
args: listArgs,
),
);Everywhere else it rides submit, which answers straight away with where the
write went:
outcome = await client.submit(
SubmitOptions(
function_path="messages:send",
args={"channel": "general", "text": "hi"},
optimistic_update=lambda store, _: store.set_query(
"messages:list",
{"channel": "general"},
[*(store.get_query("messages:list", {"channel": "general"}) or []), pending],
),
)
)
outcome.status # "committed", or "queued" while the socket is downA per-call optimistic patches the query subscribed under the mutation's own path and args — the shorthand for a counter or a document-by-id, where a
query and a mutation share both. To patch a differently-named query, which is the usual case, use optimisticUpdate: its store names its targets.
Two things are worth knowing about the models everywhere:
- An argument or result carrying a
v.bigint()orv.bytes()stays untyped. JSON Schema describes both as a plain integer and a plain string, but the wire needs a tagged value no generated field can produce, so no model is emitted and the call takes wire values directly.lunora sdk generatenames the functions. - A result is only typed if you declare
.output(). Without one the return type is inferred by TypeScript and absent from the schema, so the SDK hands back its language'sanyrather than guessing a shape.
HTTP and the socket are injected in every language rather than assumed, so you keep your own stack, timeouts, retries and socket library, and the conformance suites run with no network.
Conformance
Every SDK is tested against the same golden frames in
protocol/fixtures/
as the reference TypeScript client, and
protocol/conformance-cases.json
lists the cases every suite must exercise. Adding a name there turns all eight
languages red until each one covers it.
CI also generates each SDK into a scratch directory outside the repository, then compiles it and runs a call through it. Building alone was not enough: an earlier revision emitted a Java surface that compiled perfectly and threw on its first invocation.
See sdks/README.md for the
contributor-side detail.