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. - Team-owned home — a team assistant
(
SESSION_DETAILS.owner_team_id is not None, exposed asSESSION_DETAILS.team_owned) has no personal root at all. Its shared tables home at the owning team’s flat root (Teams/{owner}/…, the same surfaces every member reads), while per-assistant runtime internals (events, caches, non-shared tables) live under a namespaced subtree,Teams/{owner}/Assistants/{agent_id}/…— still team-owned, just collision-free when a team owns several assistants. Task-machine state (unify/task_scheduler/machine_state.py) routes to the team’sTeams/{owner}/Tasks/…tree for the same reason. The root itself is resolved inunify/common/runtime_context.py’sresolve_runtime_context_root().
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.
destination=None (and, forgivingly, "personal") resolves shared tables
to the owning team’s root instead of a personal one, read_roots returns
the owning team first followed by other member teams — with no personal
root anywhere — and non-shared tables resolve to the
Teams/{owner}/Assistants/{agent} subtree. The owning team also passes
the membership gate unconditionally and is protected from
forget_departed_team_roots eviction: it’s the assistant’s home, not a
revocable membership.
Destination parsing is centralized in
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; a
team-owned assistant’s owning team is always included, membership payload
or not), 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_fields/excluded_fieldsprojections, and an optional foreignproject(used for the read-only platform builtins catalogue, which lives outside the active project).federated_filter(contexts, filter=..., sorting=...)— exact federated structured reads. These are server-side: one call to the backend’s federated logs endpoint (unisdk.get_logs_federated, i.e.POST /logs/federated) runs every context through the ordinary single-context query pipeline and performs the exact global merge on the server — one round trip, globally-ordered window, exact totals — instead of paging each context over HTTP and merging client-side.SortSpechandles the subtle NULLs-ordering problem: the backend sorts NULLs last, so a sort key asking formissing="first"forces full branch fetches. Afetcherescape hatch retains the client-side merge path for rows that don’t come from the logs API (local stores, impl-specific reads).federated_count(contexts, key=...)— a count-only federated read (one server call withlimit=0).federated_ranked_search(contexts, references, ...)— exact federated top-k semantic search. This path stays client-orchestrated (it depends on per-context embedding columns): 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_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) fetch the merged rows through the federated endpoint and reduce client-side.- Missing contexts (a table not yet provisioned under some root) contribute nothing, 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 three
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.owner_team_id: int | None— the owning team for team assistants (None= user-owned), withSESSION_DETAILS.team_ownedas the derived flag that switches the registry, root resolution, task-machine routing, and prompt layer into team-owned mode.
export_team_ids_to_env / export_team_summaries_to_env encode
memberships into TEAM_IDS (CSV) and TEAM_SUMMARIES (JSON),
OWNER_TEAM_ID carries ownership, and SESSION_DETAILS re-hydrates from
those vars on the other side via normalize_team_summaries /
populate_from_env. All three arrive in the startup payload from
Orchestra’s assistant record (which carries owner_team_id for team-owned
assistants).
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.
Team-owned sessions render a distinct variant: the identity line becomes
“You are a team-owned assistant: your owning team’s shared root is your
home memory… You have no private personal memory”, the owning team’s
bullet is marked as the default destination, the personal bullet
disappears, and the routing rules swap “default to personal” for “writes
default to your owning team’s shared root.”
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, the
owner_team_id ownership column and its lifecycle guards (an owning
team refuses deletion while it owns assistants; a team assistant can’t
be removed from its owning team), membership endpoints, coordinator
auto-enrollment, org-wide sharing, per-team spend attribution, and the
purge of Teams/{id}/… trees on team deletion — lives in the
closed-source Orchestra backend, which the runtime reaches through
UniSDK.