Python Quickstart¶
The uniko Python SDK is the same in-process cognitive memory engine you get in Rust, wrapped in an
idiomatic asyncio package. You observe a conversation, recall grounded context, answer questions,
run goals and tasks, and reason hypothetically — all from Python, with the heavy lifting (storage,
vector search, local NLP, the recall cascade) running natively in-process.
Every facade verb ships in two skins: the bare name returns an awaitable for asyncio, and the
*_sync twin blocks the calling thread on the shared runtime and returns the resolved value. The
sync twins release the GIL across the native work, so you can call them from threads without
serializing your whole program. The examples below show both, side by side.
Install¶
The package is uniko (importable as uniko, native extension at uniko._uniko). Build it with
maturin from the bindings crate:
That compiles the native extension into your active virtualenv. You need Python ≥ 3.10, a
working C/C++ toolchain, and protoc (the Protocol Buffers compiler) on your PATH; the
ONNX runtime is linked statically, so there is no separate install for the inference path.
Use a virtualenv
maturin develop installs into the currently-activated environment. Create and activate a
venv (or use uv/poetry) first so the build lands where you expect.
Ships typed
The package includes py.typed and a hand-written _uniko.pyi stub, so your editor and type
checker see full signatures for every handle, builder, and value object.
A guided tour¶
The walk-through below is one continuous program. Each step shows the async and sync forms; pick the style that fits your application.
1. Open an instance, mint an agent and a session¶
A Uniko instance owns the whole memory system. From it you mint an Agent (an identity), and from
the agent a Session (a conversation thread). in_memory() is ephemeral — perfect for tests and
notebooks; open(path) gives you a durable, file-backed store.
agent(...) and session(...) are synchronous constructors — no await, no _sync twin. For
tuned setups (an LLM for answer(), streaming ingest, agent-scoped recall) reach for
uniko.Uniko.builder() instead; see the UnikoBuilder reference.
2. Observe a turn¶
observe runs the full ingest pipeline — chunking, entity extraction, observation extraction — and
commits before returning, so the very next read sees it (read-after-write). A Turn is a
plain constructor, Turn(sender_id, content), reusable and chainable.
The returned ObserveResult tells you exactly what landed: message_node_id, chunk_node_ids,
session_node_id, extracted_entities (a list of (node_id, label) tuples),
extracted_observations, and attachment_count.
Sessions are serialized
observe mutates the session in order — feed turns sequentially, not concurrently, within one
Session.
A Turn carries more than text. Chain .id(message_id) for idempotency, .at(datetime),
.addressed_to([...]), .content_type("..."), .metadata(key, value), and
.attach(IngestSource.from_path("handbook.md")) to ride a document along with the message.
3. Recall a context bundle¶
recall returns a ContextBundle — the ranked, deduplicated context for a query, with no LLM
involved. Iterate .items; each RecallItem carries its kind, score, content, and the
sources it traces back to.
ContextBundle supports len(bundle) and exposes phase1_only, phase2_only, and coverage
alongside the items — everything you need to format your own prompt or audit what grounded a result.
4. Answer a question¶
answer runs the recall cascade, hands the ranked context to the configured LLM, and returns an
Answer with the synthesized text plus the context that grounded it. This requires an LLM — if
none is configured, it raises ConfigError.
answer.context is the same ContextBundle you'd get from recall, and answer.citations()
lists the RecallSources the reply drew on. To configure a model, build with an LlmSpec:
spec = uniko.LlmSpec.openai("llm/default", "gpt-4o-mini") # key from OPENAI_API_KEY
uni = await uniko.Uniko.builder().in_memory().llm(spec).build()
LlmSpec also offers openai_with_key_env(alias, model_id, key_env) and mistralrs(alias,
model_id) for a local, in-situ quantized model.
No LLM, no answer
with pytest.raises(uniko.ConfigError) as info:
await agent.answer("anything")
assert info.value.kind == "config"
recall never needs an LLM — only answer/answer_in do.
5. Run a raw Cypher query¶
query runs read-only Cypher and returns plain list[dict] rows. Mutations are rejected.
6. Goals and tasks¶
Every agent has a goals surface for structured planning. Reads are scoped to the agent's owned
goals and assigned tasks; transitions return bool (False when the id doesn't resolve); creation
methods return the new node id.
The agent must exist first
Goals attach to the agent's Participant, which is created on first sight. Send one turn before
creating goals.
await agent.session("setup").observe(uniko.Turn("assistant", "kicking off"))
goals = agent.goals
gid = await goals.create(
"Ship the Python SDK",
goal_id="g1",
description="PyO3 bindings",
metrics={"target_phase": 3},
)
await goals.start("g1")
tid = await goals.create_task("write tests", goal_id="g1", task_id="t1", priority=0.8)
active = await goals.active()
tasks = await goals.tasks_of("g1")
ctx = await goals.context("g1")
await goals.complete("g1", {"done": True})
agent.session("setup").observe_sync(uniko.Turn("assistant", "kicking off"))
goals = agent.goals
gid = goals.create_sync(
"Ship the Python SDK",
goal_id="g1",
description="PyO3 bindings",
metrics={"target_phase": 3},
)
goals.start_sync("g1")
tid = goals.create_task_sync("write tests", goal_id="g1", task_id="t1", priority=0.8)
active = goals.active_sync()
tasks = goals.tasks_of_sync("g1")
ctx = goals.context_sync("g1")
goals.complete_sync("g1", {"done": True})
Reads include all(), active(), planned(), completed(), in_phase(phase), get(goal_id),
tasks(), tasks_in(phase), tasks_of(goal_id), and context(goal_id). Goal phases are the plain
strings planned/active/completed/abandoned; task phases are
planned/active/completed/blocked. Task transitions are start_task, block_task,
complete_task, and set_task_status — to leave the blocked state, call start_task,
complete_task, or set_task_status.
Invalid phases raise early
Passing an unknown phase to in_phase or tasks_in raises ValueError before any query runs.
7. Reason hypothetically with assume¶
agent.assume(...) opens a hypothetical transaction: you stage mutations, query against them, and
the changes roll back when the builder runs — the real graph is never touched. Build it with
.then_query(...), optionally .param(key, value), then terminate with run()/run_sync().
rows = await (
agent.assume(
"ASSUME { CREATE (:Fact {fact_id: 'srv-port', subject: 'srv', "
"predicate: 'port', object: '9090'}) }"
)
.then_query("MATCH (f:Fact {subject: 'srv'}) RETURN f")
.run()
)
assert len(rows) == 1
# The hypothetical mutation never hit the real graph:
after = await agent.query("MATCH (f:Fact {subject: 'srv'}) RETURN f")
assert after == []
rows = (
agent.assume(
"ASSUME { CREATE (:Fact {fact_id: 'srv-port', subject: 'srv', "
"predicate: 'port', object: '9090'}) }"
)
.then_query("MATCH (f:Fact {subject: 'srv'}) RETURN f")
.run_sync()
)
assert len(rows) == 1
after = agent.query_sync("MATCH (f:Fact {subject: 'srv'}) RETURN f")
assert after == []
For the inverse — abductive reasoning that proposes what would make a query hold — define a rule
and call abduce:
AbductionResult supports len(result); each entry in result.modifications is a dict with keys
modification, validated, and cost.
8. Shut down cleanly¶
shutdown drains the workers and consumes the handle. Because the store is reference-counted,
you must drop every derived Agent/Session first — otherwise the guard raises ConfigError. Use
purge() to wipe the whole graph for a dev/test reset.
Any use of a Uniko handle after shutdown raises ConfigError.
Handling conflicts¶
uniko raises a small, predictable exception hierarchy. The base is UnikoError, and every
instance carries a .kind string. Subclasses include ConfigError ("config"), LlmError
("llm"), TimeoutError ("timeout"), UnsupportedError ("unsupported", no extractor for the
modality), and ConflictError ("conflict"). Storage-domain failures fold into the base
UnikoError but still carry distinct .kind values ("storage", "search", "schema",
"pipeline", "locy", "embedding", "internal") — branch on e.kind for those.
ConflictError is a transient serialization abort (SSI / antidependency) and is safe to retry:
import asyncio
import uniko
async def observe_with_retry(session, turn, attempts=5):
for i in range(attempts):
try:
return await session.observe(turn)
except uniko.ConflictError:
if i == attempts - 1:
raise
await asyncio.sleep(0.05 * (2 ** i)) # back off and try again
Branch on .kind, not just type
Because storage-domain errors all surface as UnikoError, the robust pattern is
except uniko.UnikoError as e: ... then match e.kind:. The named subclasses
(ConfigError, ConflictError, …) are there when you want to catch one specifically.
Next steps¶
Python API Reference¶
The full facade surface — every handle, builder, value object, and the config keys behind config().
Recall & Retrieval¶
How recall/answer rank context, what each RecallItem kind means, and tracing sources.
Reasoning with Locy¶
The logic behind assume, abduce, define_rule, and run_rule.
Memory Model¶
The node types — Message, Observation, Fact, Entity, Episode — and how memory is organized.