A pure-Python (stdlib-only) client and servable peer for the ikigai wire protocol over Unix domain sockets. This is L0 of the polyglot ladder: zero Rust, zero core changes — a Python process can drive a running ikigai kernel, and a Python process can be resources that a Rust host mounts.
A binding = client + servable peer space; the module mechanism IS mount-over-wire.
Wire protocol version: 8 (ikigai.PROTOCOL_VERSION), backward
compatible down to 7 (ikigai.MIN_PROTOCOL_VERSION): the connection
opens with a version hello each way — REQUIRED since v7 (the pre-v6
tolerances are gone) — and failures cross the wire typed (see the
wire-protocol notes below). v8 adds one error variant, Conflict; a v7
host (ikigai-cli 0.1.29 and earlier) still talks to this package in both
directions. A v6 peer still fails cleanly: the hello itself names both
versions.
pip install . # zero runtime dependencies (socket/asyncio/struct only)Dev setup: pip install -e '.[dev]', then ruff check .,
ruff format --check ., pytest. The integration tests drive the real
ikigai binary and skip themselves when it is not on PATH.
This package only speaks to a running kernel; it binds nothing itself. Every
example below names resources in the urn:iki: namespace, which the Rust
host adopted in ikigai-cli 0.1.18. So:
Requires
ikigai-cli>= 0.1.18. Nothing mechanical checks this — Python packaging cannot express a floor on a Rust binary — so it is stated here instead. On an older host every example in this README fails withno endpoint resolved for urn:iki:fn:toUpper, and that message is the only symptom: the name is simply unknown there. Older hosts usedurn:fn:.
cargo install ikigai-cli --locked # NOT `cargo install ikigai` — that is an
# unrelated crate by another author; ours
# publishes as `ikigai-cli` and installs a
# binary named `ikigai`.
ikigai -c 'source urn:iki:fn:toUpper in="hi"' # HI ⇒ your host is new enough0.1.18 also aliases the old spelling, so urn:fn:toUpper still resolves there
— but the alias canonicalizes before anything observes the name. A request
for urn:fn:nope comes back as no endpoint resolved for urn:iki:fn:nope, and
trace events report urn:iki:fn:toUpper whichever spelling you sent. Code that
matches on returned IRIs must expect the canonical form.
import ikigai
k = ikigai.connect() # default socket path, same as the Rust CLI
rep = k.source("urn:iki:fn:toUpper", **{"in": "hi"})
rep.text # "HI"
rep.media_type # "text/plain;charset=utf-8"
rep.cache_status # how the server's cache answered (HIT/MISS/UNCACHEABLE)
k.sink("urn:file:notes.txt", "content goes as the `content` arg")
k.exists("urn:file:notes.txt") # "true" — the file the sink just wrote
# NB exists still routes through the endpoint, so a function endpoint wants its
# required args: k.exists("urn:iki:fn:toUpper", **{"in": "hi"})
k.meta("urn:iki:fn:toUpper") # self-description, text/turtle by default
k.describe("urn:iki:fn:toUpper") # the JSON Meta face, parsed — ArgSpecs and all
k.entries() # the catalog: [SpaceEntry(pattern, endpoint, origin)]
k.is_cached("urn:iki:fn:toUpper", **{"in": "hi"})
k.source_traced("urn:iki:fn:toUpper", **{"in": "hi"}) # (rep, [TraceEvent…])
k.close() # or use it as a context managerNotes:
inis a Python keyword, so pass it as**{"in": ...}(or name your own endpoint arguments something friendlier).connect(capability=ikigai.Capability.scoped([...]))sends requests asCall::IssueAsunder that capability; the server clamps it to the principal the channel authenticated.- Errors surface typed (wire v7): the server's failure crosses with its
taxonomy intact and is raised as the matching subclass of
ikigai.EndpointError—UnresolvedError,MissingArgumentError,InvalidArgumentError(with.name/.detail),DeniedError,NotFoundError,ikigai.TimeoutError(also abuiltins.TimeoutError),UnavailableError, andConflictError(wire v8: the thing exists and its current state refuses the request — permanent, an HTTP 409; a v7 server sends the same failure as a plainEndpointError("conflict: …"))..messageis the endpoint's own message;.transientisTrueonly for Timeout/Unavailable (re-issuing may succeed — what retry/failover logic gates on). A plainexcept ikigai.EndpointErrorstill catches everything. A dead socket raisesikigai.ConnectionLost; a hung server trips the read deadline (default 300 s — long resolutions are silent, so silence is not proof of death; same rationale as the Rust client).
ikigai.aio exposes the same surface as async methods over asyncio
streams, sharing the same codec:
from ikigai import aio
k = await aio.connect()
rep = await k.source("urn:iki:fn:toUpper", **{"in": "hi"})
await k.close()For web apps, aio.lifespan(path) packages the connect/publish/close cycle
as an ASGI-style lifespan — one kernel connection for the app's lifetime,
published on app.state.kernel. It is usable directly by Litestar
(lifespan=[aio.lifespan(path)]) and adapts in one line for
FastHTML/Starlette and Falcon; the docstring carries a snippet per
framework.
from ikigai import serve, endpoint
@endpoint("urn:py:hello", summary="Greet someone")
def hello(who: str, greeting: str = "Hello") -> str:
return f"{greeting}, {who}!"
serve([hello], "/tmp/py.sock") # blocks; speaks the wire protocolThe signature is the contract. With no args= list the ArgSpecs are
derived from the function signature:
who: str→ required,xsd:string;greeting: str = "Hello"→ optional with that default.int→xsd:integer,float→xsd:double,bool→xsd:boolean,bytes→ accepted with no class (raw bytes).- Incoming wire text is coerced back to the annotated type before the
handler runs (
times="3"arrives asint3,loud="true"asTrue— the REPL'strue/falseconvention); a value that will not coerce is an endpoint error, not a handler crash. typing.Literal["fast", "slow"]→one_of(enforced at invocation).Optional[T]/T | None→ optional; absent without a default, the handler receivesNone.Annotated[str, "the name to greet"]→ the per-argument summary.- A trailing underscore maps a reserved word onto the wire:
def rev(in_: str)declares and receives the argumentin(PEP 8's own convention) — no**kwargsworkaround needed. - Unannotated parameters are accepted with no class — gradual typing, gradually rewarded; annotations are never required.
An explicit args= list of spec dicts still works unchanged and wins
wholesale over the signature (no merging; handlers then receive the wire
text uncoerced, exactly as before). A name mismatch between the explicit
list and the signature raises at decoration time.
Then from a Rust host:
ikigai --mount urn:py:=/tmp/py.sock -c 'source urn:py:hello who=Ada'
# Hello, Ada!
ikigai --mount urn:py:=/tmp/py.sock -c list
# urn:py:hello → hello [/tmp/py.sock]Or run the packaged demo: python -m ikigai.demo [socket-path].
What a served endpoint gets for free, because its describe face is real:
- Named-arg routing: the host engine fetches the JSON Meta face and
routes
who=Adaby the declared ArgSpecs — names,required/optional,class(XSD datatype or rdfs:Class IRI),default,one_of. - Catalog membership:
liston the host shows the Python endpoints with their mount origin. - Host-side caching: declare
cacheable=Trueon a pure function and the representation crosses the wire withExpiry::Never— the host kernel caches it (this peer keeps no cache;IsCachedanswers false). - Tracing: a traced resolution through the mount gets a span for the Python invocation stitched into the host's execution tree.
- Meta faces:
text/turtle(default — skolemizedik:graph, no blank nodes),text/plain,application/json.
One door can answer a whole family of names, and more than one verb.
family() declares a URI template; each verb gets its own handler and its
own contract (core's per-verb ActionSpec form):
from ikigai import family, serve, NotFoundError
cell = family("urn:iki:tutorial:ttt:stored:{x}:{y}", id="ttt-stored")
marks = {}
@cell.source(cacheable=True)
def read(x: int, y: int) -> str:
if (x, y) not in marks:
raise NotFoundError(f"nothing has been played at {x},{y}")
return marks[(x, y)]
@cell.sink
def play(x: int, y: int, content: str) -> str:
marks[(x, y)] = content.strip()
return "ok"
@cell.delete
def clear(x: int, y: int) -> str:
marks.pop((x, y), None)
return "ok"
serve([cell], "/tmp/ttt.sock")- Templates mirror
ikigai_core::UriTemplateexactly (ikigai.UriTemplate; the rule is insrc/ikigai/template.py). Level 1{var}only; a variable captures up to the leftmost occurrence of the literal after it, a trailing one takes the rest, and every capture is non-empty. The first declared door that matches wins, as in anEndpointSpace— declare the specific before the general (a door an earlier template swallows is unreachable, as in Rust; the same pattern declared twice is refused).@endpointtakes a template too, for a Source-only family. - Bindings arrive as the handler parameters of the same name, typed by
their annotations, and are described as
ik:source "binding"inputs of every action. The catalog lists the template, so the host'slist, topology and selection see the family. A binding is part of the resource's name, so anintbinding accepts one spelling per integer:01,+1,-0areInvalidArgument(two spellings would be two cache entries and two golden threads over one piece of state). - Verbs:
.source,.sink,.delete,.exists, bare or with(summary=…, args=…, output=…, requires=…). A Sink's body arrives ascontent— the host engine routes every piped or trailing value there, so every Sink declares a requiredcontenteven if its handler does not ask.source/existsmay becacheable=True; a Sink or Delete answer never is. Exists defaults to "Source would succeed" (NotFoundError→false, cacheable exactly when Source is); a family with no Source refuses it. An undeclared verb is refused, naming the verbs the door does answer. - Invalidation is the host's: a Rust host (ikigai-core ≥ 0.1.73) cuts the
target's golden thread after every Sink or Delete it forwards, so the cached
read above needs no code here.
tests/test_integration.pyproves it through the installed host. - Mount a family with
--override, which forwards IRIs unchanged:ikigai --override urn:iki:tutorial:ttt:stored:=/tmp/ttt.sock. An alias--mountworks only at the first segment (--mount urn:iki:=…), because this server can strip only what it can guess — see below.
examples/tictactoe_store.py is the ikigai book's tic-tac-toe atom served
this way — the Rust stored_cell's contract, message for message — for a
Rust host to mount under everything else the game computes.
--mount urn:py:=<socket> is an alias mount: the host rewrites
urn:py:hello → urn:hello before forwarding, and re-prefixes catalog
patterns coming back. This server therefore answers both the declared IRI
and its alias-stripped form (urn:py:echo/{m} also answers as urn:echo/{m}).
It strips exactly the first segment — the only prefix a peer can guess, since
the hello says THAT the mount aliases but not at which prefix — so a deeper
alias prefix reaches nothing here; use --override for that. Each connection's hello declares its mount mode
(the hello is required since wire v7), and entries answers accordingly
per connection: an alias mount sees the stripped patterns, a verbatim
client (plain --connect, --override, --prefer) sees the declared IRIs
— from the same server, at the same time. The strip_alias constructor
default now only governs direct Space.entries() calls. Either way
invocation always works — only the catalog view is affected.
- Return
strorbytes(encoded with the endpoint's declaredoutputmedia type), a(value, media_type)tuple, or a fullikigai.Representation. - Failures cross the wire typed (wire v7): an unknown IRI is
Unresolved, a missing required argumentMissingArgument, an unusable valueInvalidArgument, and a raised exception anEndpointerror — never a hang, and the host rebuilds the same variant natively. - A handler may raise the taxonomy deliberately —
raise ikigai.NotFoundError("no such row"),DeniedError,TimeoutError,UnavailableError,ConflictError— and the variant crosses intact: the far side's HTTP face answers 404/403/503/409 instead of a blanket 502, and transient failures stay transient for retry/failover logic. A v7 peer cannot receiveConflict, so the server sends it that one asEndpoint("conflict: …")— byte-identical to what v7 saw before. - Arguments arrive utf-8-decoded (bytes if not valid utf-8). By-reference
arguments (
ArgRef::Reference/Content) are refused loudly (asInvalidArgument): an L0 peer has no back-channel to the host to dereference them.
The ikigai book builds tic-tac-toe as resources (the tutorial's
crates/tic-tac-toe), and ships its HTML as template resources so that
any host in any language can render the same board. This package plays both
ends of that game around a Rust kernel:
- the state:
examples/tictactoe_store.pyserves the stored cell,urn:iki:tutorial:ttt:stored:{x}:{y}— the only state the game has; - the rendering:
examples/tictactoe_app.pyis a standard-library web app (http.server) that fills the game's templates, in Python, from the host's raw resources —template:{name},cell:{x}:{y},winner,turn— and serves the page, the vendored htmx and the book's stylesheet. The templates are written in ikigai-fn's template language ($h{…},$r{…}and$a{…}markers,{x}arguments,urn:iki:fn:conditional); the app implements the subset the tutorial's README states, and the templates, not the app, choose which square or status to show; - the middle:
ttt-host(the tutorial'scrates/ttt-host) holds the rules, the lines, the board and the turn, and does the resolution, the composition, the caching and the invalidation.
python -m examples.tictactoe_store /tmp/ttt/store.sock &
ttt-host --socket /tmp/ttt/host.sock --game py=/tmp/ttt/store.sock &
python -m examples.tictactoe_app --socket /tmp/ttt/host.sock
# open http://127.0.0.1:8072/game/py/(Keep socket paths short: macOS allows 104 bytes. ttt-host also serves the
same board itself on port 8070; the Deno face's app uses 8071, this one
8072.)
The app's fragments are byte-for-byte the host's own view:board and
view:status: tests/test_tictactoe_app.py plays one game through the app
and a twin game through the Rust views and compares every board, status and
reply, through a won game, refusals and a draw — and the page and its three
static files against ttt-host's own HTTP face (it skips when ttt-host is
not installed). Every other request to a view is answered as ttt-host
answers it, status and body: a trailing slash, an unknown game, a wrong
method, a coordinate spelled 01 or +1, a malformed percent-escape. The test asks both of them the
same table of edge requests (ikigai-deno's rows, with this face's added) and
requires the same status, body, Content-Type and Allow. A path that is not
a view gets 404 not found, because the app is not a proxy for the host's
other names. The filler is the template language's scanner, its three
splices and conditional, and it runs the README's template cases (the ones
the tutorial runs through ikigai-fn's own compose). It keeps no state and caches nothing, because every read it makes
is a cache hit in the host until a move cuts it.
Two honest limits, both about the kernel in the middle:
- Always write through the host. The app Sinks the host's
move:{x}:{y}andreset, never the store. The host cuts a stored cell's golden thread when ITS kernel issues the write; a write straight to the Python store (another client of that socket) would leave the host serving the old board until something else cut it. There is no golden thread over the wire yet. - Render from raw resources, never compute the game. The app asks the
host for
winnerrather than working it out from the cells. A Python composite that read the cells back through the host and decided the winner itself would be a traccessor: an answer built from reads the host never saw it make, so the host could not track its dependencies or ever cut it.
examples/ shows three web frameworks built on this client — Litestar
(typed handlers), Falcon (bare ASGI), FastHTML (hypermedia/htmx) — each a
thin face over kernel.source(...), with a browsable catalog and the wire's
typed errors mapped onto HTTP statuses: DeniedError→403,
NotFoundError→404, ConflictError→409,
MissingArgumentError/InvalidArgumentError→400,
transient (TimeoutError/UnavailableError)→503, anything else→502, and
ConnectionLost→503. They run pure-Python against
python -m examples.endpoints, or through a Rust kernel to pick up its
caching unchanged. See examples/README.md; install with
pip install -e '.[dev,examples]'.
A UDS peer trusts its connections: the socket is 0600, and both the Python
server and the Rust server refuse peers whose kernel-verified UID (SO_PEERCRED
/ LOCAL_PEERCRED) differs from their own. A capability carried on
IssueAs/IssueTraced is accepted and surfaced (e.g. in trace spans) but
not enforced per-scope — capability-on-the-wire for IPC is a known TODO on
the Rust side too; do not treat a Python peer as a capability boundary.
ikigai.wire mirrors ikigai-wire (Rust) field-for-field; its docstrings
record the layout. Highlights that a public ABI document should state:
- Framing:
u32big-endian length + postcard payload; 64 MiB frame cap, checked before allocation. - Version hello (since v6, REQUIRED since v7). The first frame each way
is
b"IKWH"+u32big-endian version +u8mode — deliberately not postcard, because the codec whose version is being negotiated must not be needed to negotiate it. Readers ignore trailing bytes; that is the extension mechanism. The mode byte (0 = verbatim, 1 = alias mount) tells a served peer how its mounter addresses it. A version mismatch is a clean error naming both sides instead of garbled postcard. The v6 one-version tolerances are gone: a server that hangs up on the hello is refused with the pre-v6 diagnosis (no legacy reconnect) — while a server that is merely silent is reported as hung or overloaded, never misdiagnosed as ancient — and a first frame without the magic is refused by the server. This package speaks v8 (ikigai.PROTOCOL_VERSION) and still raisesProtocolErrornaming its version on any undecodable message. - Backward compatibility (since v8). A v8 side accepts a hello of v7 or
v8 (
MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION); anything else is refused as before, naming both versions. The server answers an accepted hello with the peer's own version (a v7 client requires a v7 answer) and the connection remembers it: a reply to a v7 peer downgradesConflict(msg)toEndpoint("conflict: {msg}"), the Rust core's rendering of it and byte-identical to what v7 received before. A v8 client that dials a v7 server gets7back and a hang-up (that is how a v7 server refuses an8), so it redials once offering 7; the offer only steps down, never below the floor.Client.server_versionsays which version a connection speaks. - Typed errors (since v7). A failure crosses as
Reply::ErrorTyped(postcard discriminant 5) carrying theWireErrorenum — variants 0–8 in declaration order:Unresolved(iri),MissingArgument(name),InvalidArgument{name, detail},Endpoint(message),Denied(message),NotFound(message),Timeout(message),Unavailable(message), and since v8Conflict(message)— an append-only, wire-local mirror ofikigai_core::Error(a taxonomy addition is a wire-version event). Reference vectors:Denied("x")is05 04 01 78,Conflict("x")is05 08 01 78. Timeout/Unavailable are transient; the rest (Conflict included) permanent. An unknown future variant degrades to the baseEndpointError, loudly named. The flatReply::Errorstring (variant 3) remains decodable but is no longer sent. - Enum discriminants are the declaration index as a varint —
Verb::Sourceis0on the wire even though it is declared#[repr(u8)] Source = 1(those codes are only for identity hashing). ContentIdcrosses as the stringb3:<hex>(serdeinto = "String"), not 32 raw bytes.Representation.threads(golden threads) is#[serde(skip)]— cache provenance never crosses the wire;expirydoes, and drives host caching.- Map/set order is Rust
BTreeMap/BTreeSetorder: lexicographic over UTF-8 bytes.
MIT OR Apache-2.0, at your option.