unifyai/unify repo, how they connect
to the hosted backend, and where to hook in if you’re extending them.
Two packages own this territory:
unify/data_manager/—DataManager, the tabular data engine.unify/canvas_manager/—CanvasManager, the generative-UI layer built on top of it.
The big picture
- Pure primitives. Neither manager has
ask/updateLLM tool loops. Frombase.py: “DataManager exposes pure primitives with no ask/update tool loops. High-level orchestration is handled by Actor composing these primitives.” The Actor writes Python that calls them directly. - Docstrings are the API docs. The
@abstractmethoddocstrings on the base classes are what the Actor (an LLM) reads to learn the API — concrete classes inherit them viafunctools.wraps. If you change behavior, the docstring is the interface; keep it truthful. - Contract-first discovery. The primitives registry
(
unify/function_manager/primitives/registry.py) introspects@abstractmethoddefinitions on the base class to decide what the Actor sees asprimitives.data.*andprimitives.canvas.*. Add an abstract method with a docstring and it becomes an Actor-callable primitive with no registry edits.
_SYNC_MANAGERS in
unify/function_manager/primitives/runtime.py;
the runtime wraps them in _AsyncPrimitiveWrapper (dispatching via
asyncio.to_thread) so Actor code can uniformly await primitives.data.filter(...).
Implementation selection is env-driven: UNITY_DATA_IMPL and
UNITY_CANVAS_IMPL ("real" | "simulated"), read by DataSettings
and CanvasSettings and resolved through ManagerRegistry —
unify/manager_registry.py.
DataManager
Package anatomy
The layering rule is strict:
base.py holds contract and docstrings only;
data_manager.py orchestrates and resolves contexts; ops/ talks to the
backend. The *_impl functions are internal — always go through the
manager.
The contract at a glance
BaseDataManager groups its 26 abstract methods into five families:
A defining property, from the class docstring: the primitives “work on
ANY Unify context”.
DataManager semantically owns the Data/*
namespace, but executes analytical operations against foreign namespaces
too — Files/*, Knowledge/*, FileRecords/* — which is exactly how
FileManager and KnowledgeManager use it (both delegate their
query/join execution to a DataManager internally; see
KnowledgeManager._data_manager and the convenience wrappers like
filter_files in
unify/file_manager/file_manager.py).
Storage model: a table is a context of log events
There is no bespoke table storage. A table is an Orchestra context (a hierarchical path likeData/Sales/Monthly) and each row is a
log event in that context, written and read through
unisdk. Row identity is the
Orchestra log ID — insert_rows returns them, filter can return them
(return_ids_only=True), and delete_rows accepts them (log_ids=).
Context paths resolve through three private helpers in DataManager:
_resolve_context(reads): strips a leading/, passes through any path starting with a known absolute prefix (Data/,Files/,Knowledge/,Teams/, …), and prepends the assistant’s base context ({org}/{assistant_id}/Data) for relative names._resolve_context_for_write(context, destination=): for Data-owned paths, routes throughContextRegistry.write_root(self, "Data", destination=...)—"personal"(default) or"team:<id>", the latter landing underTeams/{id}/Data/...and requiring live team membership._resolve_contexts_for_read: for Data-owned, non-exact paths, fans out acrossContextRegistry.read_roots(...)— the personal root plus every accessible team root — merging results viafederated_filter,federated_ranked_search, andfederated_reducefromunify/common/federated_search.py.
ContextRegistry
(unify/common/context_registry.py)
is the single choke point for scope: write_root provisions and returns
exactly one destination, read_roots returns the ordered fan-out list,
and invalid destinations (unknown team, non-member team) raise
ToolErrorException with error_kind: "invalid_destination".
Two Data-specific conveniences applied at creation time: tables under
Data/* default to unique_keys={"row_id": "int"} and
auto_counting={"row_id": None} when the caller doesn’t specify them
(_resolve_unique_keys_and_auto_counting).
The query engine
Filter.filter takes a Python-like boolean expression over column
names — "amount > 1000 and status == 'open'". The string passes through
normalize_filter_expr
(unify/common/filter_utils.py,
currently a passthrough) and is evaluated server-side by Orchestra,
which compiles the expression against each row’s JSON. Private columns
(leading _) are excluded from results unless explicitly requested.
limit is capped at 1000.
Search. Semantic search rides on derived embedding columns. A
column text gets a private sibling _text_emb whose value is a derived
equation embed({lg:text}, model='text-embedding-3-small');
ensure_vector_column / vectorize_rows (delegating to
ensure_derived_column in
unify/common/embed_utils.py)
create and backfill these. A search call maps column → reference text
(references={"description": "billing complaints"}), ranks by cosine
similarity server-side, and averages across terms when several columns are
given. Expression sources hash into derived columns (_expr_<hash>)
before embedding.
Reduce. Aggregations (count, sum, mean, var, std, min,
max, median, mode) with optional group_by, executed by Orchestra’s
metrics endpoint. Note for extenders: DataManager.reduce calls
federated_reduce (not reduce_impl directly) so decomposable metrics
merge correctly across personal and team roots.
Joins. Two execution shapes:
join_tablesmaterializes a joined table into a destination context (unisdk.join_logs) — use when the join result is itself a dataset.filter_join/reduce_joinare ephemeral, fused queries (unisdk.join_query) — a single server round-trip returning rows or aggregates, no temp context.search_joinjoins into a temp context, searches semantically, and cleans up. The*_multi_joinvariants chain steps, with$prevreferencing the previous step’s result.
"Data/orders.customer_id == Data/customers.id"), and DataManager
rewrites those paths per root group when federating across personal and
team scopes (rewrite_join_paths in
unify/common/join_utils.py).
The ingest pipeline
ingest is the preferred bulk-load path and the most mechanically
interesting op. run_ingest in
ops/ingest_ops.py
builds a TaskGraph and hands it to PipelineExecutor
(utils/pipeline.py),
a small thread-pooled DAG engine with retries, backoff, and a scheduling
policy that prioritizes downstream tasks (so embedding can run along
inserts instead of degenerating to after them):
- Type prescan —
ops/type_prescan.pyinfers column types from a stratified sample (prescan_column_types→TypeMap) andcoerce_batchcleans each chunk (empty strings →None, type mismatches →None, tallied inCoercionStats). - Create + chunked insert — table created if needed, rows inserted in
chunks (default 1000), serialized when
auto_countingdemands it. - Optional embedding — vector columns ensured and backfilled, batched.
- Post-ingest derived columns —
PostIngestConfigrules (ExplicitDerivedColumnwith anequationlike"{unit_price} * {quantity}", orAutoDerivedColumnby source type).
IngestResult (rows_inserted, rows_embedded,
chunks_processed, coercion_stats, duration_ms, …), and
IngestExecutionConfig exposes the pipeline knobs (max_workers,
insert_parallelism, embedding_batch_size, fail_fast).
Mutation semantics worth knowing
update_rowsis implemented as delete + re-insert inops/mutation_ops.py— not an atomic field update.update_rows/delete_rowsrequire a filter (or explicitlog_ids); destructive table ops requiredangerous_ok=True.- Writes into shared team contexts strip authorship fields via
is_shared_authored_context(unify/common/authorship.py). describe_tabledeliberately omitsrow_count(expensive); usereduce(metric="count").
The simulated backend
SimulatedDataManager keeps everything in dicts (_tables, _schemas,
_embeddings, …) and is honest about its shortcuts: filters run through
local eval, search fakes ranking with word overlap, joins are simplified
merges. It exists so Actor evals and unit tests run with zero backend —
don’t use it to validate join-expression correctness. Fixtures live in
tests/data_manager/
(simulated_dm, seeded_dm), and tests/data_manager/ doubles as the
best map of behavioral guarantees: context resolution, destination
routing, streaming ingest, type prescan, pipeline mechanics.
Two orphaned modules —
ops/plot_ops.py and ops/table_view_ops.py —
remain from the era when DataManager rendered visuals. plot() and
table_view() were removed from the public contract; all visual
output now goes through CanvasManager.CanvasManager
Design in one paragraph
The Actor writes a real React module — TSX importing onlyreact and
@unity/canvas-kit — and hands it to create_view together with declared
query bindings and actions. The manager lints, typechecks and
bundles the module, dry-runs every binding through the live DataManager,
verifies every action target exists, renders the view headlessly and
critiques the result before anything is published. The stored row carries
the source, the compiled bundle and its sha256; a 12-character routing
token is registered with the backend, and the Console serves the view in a
genuinely isolated frame. There is no raw-HTML path and no unauthenticated
token-is-access URL — both were properties of the dashboard tiles this
layer replaced, and both were retired with them.
Package anatomy
Rows live in three registered contexts —
Canvas/Views, Canvas/Actions,
Canvas/Invocations — written through the data layer like every other
manager. There is no separate layout table: React composes, so a
multi-tile board is simply one view, and the dangling-reference bug class
of a layout pointing at a deleted tile cannot exist.
The binding type system
A view declares its data asPrimitiveBinding entries — an alias the
canvas reads via canvas.data[alias], the owning manager and table, and
one of four read-only query shapes mirroring the DataManager families
(filter, reduce, join, join_reduce). Every binding is dry-run at
author time, and at view time the server executes only the stored
binding a client names by alias: no context path, filter expression or row
limit is ever accepted from the viewer, which is the security inversion
that retired the old tile bridge.
Actions and invocations
A view may declareCanvasActions: named operations with JSON-Schema
input (every string and array bounded), an execution lane (stored
function, task trigger, or assistant request), optional confirmation text
rendered by the Console outside the frame, and per-hour rate limits.
A viewer’s invocation is validated server-side, recorded as a
Canvas/Invocations row, then executed by the assistant — the frame never
learns which function or task sits behind an action.
destination and visibility
destination— where the view’s row lives ("personal"or"team:<id>"), which controls who sees it in listings.visibility— who may read it (private,team,public_link), enforced server-side on every read path; publishing and quarantining flipstatuswithout reissuing the URL.
Extending
A new binding shape touches the full stack — in this repo: a new args model with a uniqueoperation literal in canvas_manager/types/binding.py;
resolution and a real DataManager dry-run in ops/binding_ops.py; and
documentation in the create_view docstring (remember: docstrings are the
Actor’s API). Orchestra then needs the matching server-side execution in
its canvas query route.
A new DataManager operation is simpler: abstract method + docstring on
BaseDataManager, an *_impl in the right ops/ module, implementations
in both the real and simulated managers — the primitives registry picks it
up automatically.
A new backend for either manager: subclass the base, implement the
contract, register via ManagerRegistry.register_class, and extend the
settings enum.