unifyai/unify repo. The pages above
describe what users experience; this one describes the code — module by
module, with the real symbol names.
Team membership (who belongs to which team) is owned by the
closed-source Orchestra backend. The unify runtime never decides
membership; it receives it, and everything on this page is about what the
runtime does with that fact: how a team:<id> destination becomes a
concrete storage root, how writes are gated, how reads federate across
every reachable scope, and how the assistant is taught to route content.
The core model: destinations, roots, and one registry
Every state manager (guidance, knowledge, functions, tasks, data, secrets, dashboards…) stores rows in contexts — hierarchical, table-like namespaces addressed by path. Team sharing is implemented as a pure namespace concern: the same tables exist under multiple roots, and one class decides which root any given operation touches.
- Personal root — the assistant’s own base context,
{user_id}/{assistant_id}/…. This is the privacy floor: content here is visible to one assistant only. - Team roots —
Teams/{team_id}/…, one per team the assistant belongs to. Contexts under a team root are owned by the team, not by any assistant, and are visible to every current member.
ContextRegistry in
unify/common/context_registry.py.
It owns the vocabulary — PERSONAL_DESTINATION = "personal",
TEAM_DESTINATION_PREFIX = "team:", TEAM_CONTEXT_PREFIX = "Teams/" — and
the two resolution primitives everything else is built on:
ContextRegistry.write_root(manager, table_name, destination=...)— resolves a public destination string to exactly one root, provisioning the context (with its schema, unique keys, and foreign keys) on first touch. Writes never fan out.ContextRegistry.read_roots(manager, table_name)— returns the ordered list of roots a read should span: the personal root first, then every team inSESSION_DETAILS.team_ids(sorted), provisioning any that don’t exist yet. Reads always fan out.
ContextRegistry.canonical_destination(...): None and "personal"
normalize to the personal root; "team:<id>" is validated for shape
(integer, non-negative) and canonicalized. Everything else raises a
structured ToolErrorException with error_kind="invalid_destination" —
the payload deliberately includes the caller’s destination, the table name,
and the assistant’s actual team_ids, so an LLM-driven caller can
self-correct instead of retrying blindly.
Two further gates live in _parse_destination:
- Table opt-in. Only tables in
SHARED_SCOPED_TABLES(see below) may take a team destination at all; anything else fails with “Table X does not support team destinations.” - Membership.
team_id not in SESSION_DETAILS.team_idsfails with “Assistant is not a member of team .” — this is the runtime enforcement point for team isolation. There is no path to a team root that bypasses it.
Which tables participate
The authoritative list isSHARED_SCOPED_TABLES in
unify/common/authorship.py:
Tasks, Contacts, Secrets, Knowledge, Guidance, the four
Functions/* tables (Compositional, Meta, Primitives,
VirtualEnvs), FileRecords, Files, Data, BlackList,
Dashboards/Tiles, Dashboards/Layouts, Transcripts, Exchanges, and
Images.
Matching is prefix-aware: ContextRegistry._is_shared_scoped walks parent
paths, so a dynamic table like Data/Samsara/daily_snapshots inherits
shared-scoping from its Data ancestor. The prefixes that admit dynamic
children are enumerated separately in DYNAMIC_AUTHORED_TABLE_PREFIXES
(Data, FileRecords, Files, Knowledge).
Authorship stamping
Shared rows need provenance: in a team context, “who wrote this?” is no longer implied by the namespace. The sameauthorship.py module handles
this:
fields_with_authoring(...)injects an immutableauthoring_assistant_idcolumn into every shared-scoped table’s schema — applied automatically byContextRegistry._get_contexts_for_managerwhenever it provisions a shared-scoped context.stamp_authoring_assistant_id(entries)stamps write payloads with the active assistant’s id (fromSESSION_DETAILS.assistant.agent_id), andstrip_authoring_assistant_id(entries)removes caller-supplied values from update payloads so authorship can’t be forged after the fact.shared_table_for_context(context)/is_shared_authored_context(context)answer the reverse question — given a concrete context path, does it store authored rows? — again with prefix-aware matching for dynamic tables.
Writes and reads, end to end

The write path
Every manager exposes the same public parameter —destination: str | None
— on its write methods (GuidanceManager.add_guidance,
KnowledgeManager’s table/row operations, FunctionManager.add_functions,
DataManager.create_table, SecretManager.add_secret,
DashboardManager.create_tile, task creation in TaskScheduler, …). The
manager passes it straight through to write_root, which:
- normalizes via
canonical_destination, - rejects non-shared tables and non-member teams,
- lazily provisions the context under the resolved root
(
_ensure_context→_create_context_wrapper), registering explicit ownership —_owner_for_rootmapsTeams/{id}roots to("team", id)and personal roots to("assistant", agent_id), which is what lets the backend bulk-delete a team’s entire tree when the team is deleted, - returns the single root context the write targets.
ContextRegistry.implicit_shared_destinations() returns the full list of
team destinations (or [None] when the assistant has no teams), and the
transcript/screenshot publishing paths in
unify/conversation_manager/domains/managers_utils.py
and the screenshot capture path in
unify/conversation_manager/conversation_manager.py
fan conversation records out across them. This is what makes a shared
team’s transcript history legible to teammates.
The read path: federated search
Reads never take a destination — they see everything the assistant can reach, merged as if it were one table. The engine isunify/common/federated_search.py,
and it’s worth understanding because every manager’s search/list/reduce
goes through it:
FederatedSearchContext— one participating context: the concrete path, asourcelabel, an optionalrow_filter, optionalallowed_fieldsprojection, and an optional foreignproject(used for the read-only platform builtins catalogue, which lives outside the active project).federated_ranked_search(contexts, references, ...)— exact federated top-k semantic search. Each context is fetched with a local window ofoffset + limit(which makes the global merge provably exact — a row outside its local window can’t be in the global one), merged by ascending embedding distance viamerge_ranked_batches, and sliced once. Optionalbackfilltops up short results with deterministic recent rows.federated_filter(contexts, filter=..., sorting=...)— exact federated structured reads, withSortSpechandling the subtle NULLs-ordering problem: the backend always sorts NULLs last, so a sort key asking formissing="first"forces a full per-context fetch instead of windowed fetching.federated_reduce(contexts, metric=..., ...)— aggregations. Decomposable ungrouped metrics (count,sum,min,max,mean) are pushed down per context and combined exactly; grouped or non-decomposable metrics (median,mode,var,std) fall back to fetching merged rows and reducing client-side.- Missing contexts (404s — a table not yet provisioned under some root)
are tolerated everywhere via
is_missing_context_error, so fan-out reads don’t require every root to be fully provisioned.
_federated_source and
_federated_context (and _federated_score for ranked reads), so callers
— and ultimately the LLM — can tell which scope a result came from.
Each manager builds its own context list from read_roots. Two
representative examples: GuidanceManager.search in
unify/guidance_manager/guidance_manager.py
federates the personal root, every team root, and the platform builtins
catalogue (via a foreign-project FederatedSearchContext); and
FunctionManager’s search in
unify/function_manager/function_manager.py
federates all reachable Functions/Compositional contexts plus
primitives. This is the mechanism behind the product-level promise that a
new team member’s assistant “knows the team SOP on day one” — retrieval
simply spans the team root.
Scope semantics that differ by manager
Most managers are pure write-one/read-all, but two have deliberately different semantics worth knowing:- Secrets —
unify/secret_manager/secret_manager.py. LLM-facing reads (listing, search) federate across the personal vault and every team vault, but runtime credential resolution does not fall back across scopes: a lookup targets exactly one vault, and a missing credential raises instead of silently borrowing from another scope. Personal credentials never leak into team memory; team credentials never leak into the personal.envmirror. - Tasks —
unify/task_scheduler/task_scheduler.py. A team task lives inTeams/{id}/Tasks(theTaskmodel carries itsdestination), but execution belongs to the creating assistant. Scheduled/offline runs export the task’s vault as the ambient destination soprimitives.secretscalls inside the run inherit the team scope — see the offline runner inunify/task_scheduler/offline_runner.py. If the owning assistant has left the team by fire time, activation is refused (destination_membership_revoked). - Dashboards —
unify/dashboard_manager/base.pyadds a second axis: a tile’s row has adestination, but its live data bindings take an independentdata_scope, so a personal watch tile can legitimately read a team’s data.
How membership reaches the runtime

Session state
unify/session_details.py
is the single source of runtime truth. AssistantDetails carries two
team fields, surfaced as convenience properties on the global
SESSION_DETAILS:
team_ids: list[int]— the memberships that gate writes and expand reads.team_summaries: list[TeamSummary]— display/routing metadata (team_id,name,description) used to teach the model where content belongs.
export_team_ids_to_env / export_team_summaries_to_env encode them into
TEAM_IDS (CSV) and TEAM_SUMMARIES (JSON), and SESSION_DETAILS
re-hydrates from those vars on the other side via
normalize_team_summaries.
Live membership updates
Membership can change mid-session — an admin removes someone from a team, a colleague is commissioned into a workspace. The runtime doesn’t restart; it processes anAssistantUpdateEvent with update_kind == "membership"
in
unify/conversation_manager/domains/event_handlers.py,
which:
- replaces
SESSION_DETAILS.team_ids/team_summariesand re-exports the env vars, - calls
ContextRegistry.forget_departed_team_roots(team_ids)— dropping every cached registry entry whose root is a team the assistant no longer belongs to, so the very next resolution re-validates against current membership, - resets the cached prompt context so the next turn renders the new Accessible shared teams block.
Teaching the model: the prompt layer
Enforcement without instruction would produce an assistant that constantly bounces offinvalid_destination errors. The instruction side lives in
unify/common/accessible_teams_block.py:
build_accessible_teams_block(team_summaries) renders a block into every
system prompt listing personal (explicitly framed as “the privacy
floor”) plus one bullet per team — team:{id} "{name}" - {description} —
followed by the routing rules: default to personal, use a team only when
content clearly matches its described domain, ask a brief clarifying
question when the audience is unclear, never invent a team id, and never
pass a team:<id> token where a contact_id belongs.
Two design details matter here:
- Team descriptions are load-bearing. The model routes content by
matching it against each team’s description (truncated at
ACCESSIBLE_TEAMS_MAX_DESCRIPTION_LENGTH), which is why the user docs push admins to write descriptions that name the team’s domain. - The privacy floor is repeated at the tool layer. Each manager’s
destinationdocstring restates it in domain-specific terms (seeDataManager.create_tableorSecretManager.add_secret— the latter adds “sharing a credential is harder to undo than re-sharing later”), so the guidance survives even in contexts where the actor only sees a single tool’s schema.
Team lifecycle tools: the coordinator surface
Creating teams and managing membership is an org-shaped power reserved for the coordinator (T-W1N) runtime, implemented inunify/coordinator_manager/coordinator_manager.py
as thin, validated wrappers over Orchestra’s API:
create_team(name=..., description=...)— creates the shared workspace object; membership is deliberately a separate step.add_team_member(team_id=..., assistant_id=... | member_user_id=...)— exactly one selector:assistant_idadds a colleague assistant directly;member_user_idadds a human org member, which (on the Orchestra side) also enrolls that member’s own coordinator — the mechanism behind “your T-W1N follows you into your teams.”remove_team_member(team_id=..., assistant_id=...),list_teams(),list_teams_for_assistant(assistant_id=...)— the audit/mutation counterparts, each returning structuredToolErrorenvelopes on failure rather than raising.
Extending the system
A few recipes that follow directly from the architecture:- Making a new table team-shareable is (almost) one line: add its name
to
SHARED_SCOPED_TABLESinauthorship.py.ContextRegistrywill start acceptingteam:<id>destinations for it, inject the authorship column on provisioning, and include team roots inread_rootsfan-out. Then thread adestinationparameter through your manager’s write methods (pass it towrite_root) and build reads on the federated helpers withread_roots. Add the docstring privacy-floor language so the actor routes it sensibly. - Adding a new read shape (a new kind of federated query) belongs in
federated_search.pynext tofederated_filter/federated_ranked_search/federated_reduce— the merge-exactness arguments in those docstrings are the contract to preserve: fetch each context with the full global window, merge once, slice once. - Debugging a scope issue almost always starts at one of three seams:
the
invalid_destinationpayload (wrong token, non-shared table, or stale membership — it tells you which),SESSION_DETAILS.team_idsvs. theTEAM_IDSenv var (a worker that didn’t inherit the env), or a cached registry entry thatforget_departed_team_rootsshould have dropped.ContextRegistry.clear()exists for test isolation; the_registrycache is keyed(manager_name, table_name, root_identity).
Everything on this page is the runtime’s half of the story. The other
half — the
Team/TeamAssistantMembership data model, membership
endpoints, coordinator auto-enrollment, org-wide sharing, and the purge
of Teams/{id}/… trees on team deletion — lives in the closed-source
Orchestra backend, which the runtime reaches through
UniSDK.