# Live dashboards Source: https://docs.unify.ai/canvas/dashboards Tiles and layouts your assistant builds on request A **dashboard** is a grid of **tiles** — individual live views — that your assistant composes for you. Ask for a chart and you get a tile; ask for "a board with our key numbers" and you get a dashboard arranged from several. ## Asking for one Describe the view, not the configuration: > "Build me a dashboard for the repairs team: open work orders by priority, > a chart of completions per week, and the average time-to-fix as a big > number up top." Your assistant creates the tiles, sizes and arranges them on a 12-column grid, and shares the result. Refinements work the same way — "make the trend chart wider", "add a tile for overdue items", "retitle it Repairs Standup". ## Tiles can be anything There's no fixed menu of widget types. A tile is whatever presents the data best: * **Charts** — bar, line, scatter, histogram, or anything more exotic the data calls for. * **Tables** — sortable, styled, as compact or detailed as you like. * **KPI cards** — single big numbers with context. * **Custom views** — maps, timelines, progress boards; if it can render in a browser, it can be a tile. ## Live by design The best tiles are **live**: instead of baking numbers in, a tile is wired to [data tables](/canvas/data) and fetches fresh values every time it's viewed. Tiles with live wiring show a **Live** badge and a per-tile **Refresh data** button. That's what makes the canvas a monitoring surface rather than a report archive: a scheduled task appends today's rows to a table, and every tile built on that table is already up to date the next time you look. ## The Dashboards pane The **Dashboards** tab on your assistant's page is the viewer for everything visual it has built — in the Console's words, *"live tiles and reports your teammate builds for you, on request."* Where the [Data pane](/canvas/data) shows the raw tables, this pane shows the views on top of them. **What it's for:** * **Your standing views, in one place.** Every dashboard and standalone tile your assistant has built for you, ready to revisit — the morning KPI board, the API monitor, last quarter's analysis. * **A live monitoring surface.** Data-bound tiles fetch fresh values when viewed, so the pane doubles as your operations screen — glance rather than ask. * **The handoff point.** Pop any view into its own tab for a shareable link, or download tiles when you need to take a chart elsewhere. **How to use it:** 1. **Pick a view.** The picker at the top switches between everything saved for this assistant, in two groups: **Dashboards** (composed boards) and **Tiles** (standalone views that aren't part of any board). Search by title when the collection grows. 2. **Read the summary card.** Each dashboard opens with its description, tile count, and last-updated time — plus **Open in new tab** for a full-screen, shareable version and **Download** to grab all tiles as a bundle. 3. **Work the tiles.** Tiles are laid out on the dashboard's grid; each collapses and expands individually (or all at once from the toolbar). Tiles wired to live data carry a **Live** badge and their own **Refresh data** button; every tile can be downloaded or popped out into its own tab. 4. **Let it keep itself fresh.** While your assistant is actively working, the pane polls automatically — a **Live** indicator and "Updated…" timestamp in the footer show you're seeing current state. A refresh button re-pulls everything on demand. If the pane says *"No dashboards yet — ask your teammate to build a view when there is data to track"*, that's exactly the right instruction — this pane only ever displays; [creating and changing views happens in chat](/canvas/dashboards#asking-for-one). ## Sharing Every tile and dashboard has its own **link** — pop it out and the URL in your browser is shareable. Send a dashboard link to a colleague and they see the same live views, no Console tour required. Dashboards can also live in a [team's shared pool](/teams/shared-context), so a whole team works from the same board — a standup dashboard the entire repairs patch checks each morning, for example. ## Changing and removing Like everything else, dashboard management is conversational: "update the KPI tile to use this quarter's target", "remove the old pipeline chart", "delete the test dashboard". Tiles and dashboards are independent — removing a dashboard doesn't destroy its tiles, so views can be recomposed freely. # The data layer Source: https://docs.unify.ai/canvas/data Structured tables your assistant builds and maintains Underneath every live view is a **table** — rows and typed columns, like a spreadsheet your assistant maintains for you. The data layer is where all of those tables live. ## How data is organized Tables are arranged like folders. A trucking operation might have `Data/Samsara/daily_snapshots`; a housing team might keep `Data/Housing/arrears` and `Data/Housing/repairs`; an online shop might have `Data/ExternalAPI/Orders`. Your assistant names and organizes tables sensibly as it works — and you can always ask it to restructure. Each table has a defined shape: columns with real types (text, numbers, dates, true/false, lists), optional unique keys so duplicates can't sneak in, and a description of where the data came from. ## Where data comes from Anything your assistant can reach can become a table: * **APIs and external systems** — pulled once, or repeatedly on a [schedule](/tasks/overview): each run appends fresh rows, and a history builds up over time. This is how you get time series — a table of API response times, daily order counts, truck locations at 9am. * **Files you share** — spreadsheets, CSVs, exports. Send them over any channel and your assistant can extract the contents into proper tables. * **Connected apps** — records from your [integrations](/integrations/overview) (CRM contacts, tickets, invoices) ingested for analysis. * **Its own computations** — intermediate results worth keeping: a cleaned dataset, a monthly aggregation, a scored list. Instead of redoing the work each time, the result is stored and reused. ## What your assistant does with it Once data is in tables, you can ask for anything you'd ask an analyst: * **Filter** — "show me the high-priority repairs." * **Aggregate** — "total spend by region", "average response time per day", counts, sums, averages, medians and more, grouped however you like. * **Combine** — "join the arrears table with the payments table and show me outstanding balances." Tables from completely different sources can be combined into one answer. * **Derive** — "add a total column: unit price times quantity." New columns computed from existing ones. * **Search by meaning** — for text-heavy tables, "find the complaints about billing" works even when no row contains the word "billing". The answers come back in chat — or become permanent [dashboard tiles](/canvas/dashboards) if they're worth watching over time. ## The Data pane The **Data** pane — in the **Brain** section of your assistant's page — is your window into the whole data layer. The Console describes it as *"everything your teammate has ingested — browse nested tables like a directory and open any to view rows"*, and that's exactly the job: it's where you verify, audit, and explore the raw material behind your assistant's answers and dashboards. **What it's for:** * **Checking what was collected.** After a [scheduled task](/tasks/overview) pulls from an API, open its table and confirm today's rows actually landed. * **Auditing an answer.** When your assistant reports a number, the table it computed from is right here — open it and see the underlying rows for yourself. * **Inspecting what feeds a dashboard.** Every live tile reads from tables in this pane; if a chart looks off, the pane shows you exactly what the data says. * **Seeing the whole estate.** Nothing is hidden: if your assistant ingested it, it's browsable here — including team-shared tables, which appear grouped under their team alongside the assistant's personal folders. **How to use it:** 1. The **Data layer** sidebar shows the folder tree. Click folders to drill in, exactly like a file directory — `Samsara` → `daily_snapshots`. 2. Open a table (the leaves of the tree) and its rows appear on the right, with a header showing **Rows**, **Loaded**, and **Columns** counts. 3. **Click a column header to sort.** Tables load 50 rows at a time — use **Load more** to page through larger ones. 4. Use the refresh button after a task run to pull the latest. The pane is deliberately **read-only** — a clean window, not an editor. Changing data (fixing a row, adding a column, deleting a table) happens the way everything does: ask your assistant. The Data pane shows the *data layer* specifically. Your assistant's other memory — contacts, transcripts, knowledge, guidance, functions — each have their own dedicated panes in the same Brain section. ## Good to know * **Honest data only.** If a source is down or a fetch fails, your assistant reports the gap — it never fills tables with plausible-looking made-up records. * **Safe by default.** Bulk updates and deletions require explicit, precise instructions; your assistant won't mass-edit a table on a vague ask. Deletions are permanent, so it confirms before destructive changes. * **Private by default.** Tables live with your assistant. Data that should be shared across a team can be stored in a [team's shared pool](/teams/shared-context) instead — say so and your assistant will put it there. * **Data vs. Knowledge.** Structured rows-and-columns material lives here; facts, documents, and policies live in **Knowledge**. Your assistant routes things to the right place automatically. # Developers Source: https://docs.unify.ai/canvas/developers How DataManager and DashboardManager actually work — a code-level tour of the open-source runtime Everything else in this section describes the canvas as a user experiences it. This page is for developers: how the data layer and the visualization layer are implemented inside the open-source [`unifyai/unify`](https://github.com/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/`](https://github.com/unifyai/unify/tree/main/unify/data_manager) — `DataManager`, the tabular data engine. * [`unify/dashboard_manager/`](https://github.com/unifyai/unify/tree/main/unify/dashboard_manager) — `DashboardManager` (soon to be renamed **CanvasManager**), the visualization layer built on top of it. ## The big picture DataManager architecture: the Actor calls primitives.data.*, which lands on DataManager (contract in base.py, implementations in ops/, schemas in types/); ContextRegistry supplies destinations; SimulatedDataManager mirrors the same contract in memory; persistence flows through unisdk HTTP to Orchestra, where contexts hold log events across personal Data/… and Teams/{id}/Data/… roots with federated reads between them. DataManager architecture: the Actor calls primitives.data.*, which lands on DataManager (contract in base.py, implementations in ops/, schemas in types/); ContextRegistry supplies destinations; SimulatedDataManager mirrors the same contract in memory; persistence flows through unisdk HTTP to Orchestra, where contexts hold log events across personal Data/… and Teams/{id}/Data/… roots with federated reads between them. Both packages follow the same layered pattern, shared with every state manager in the repo: ```text theme={null} BaseStateManager └── BaseDataManager / BaseDashboardManager (base.py — the contract) ├── DataManager / DashboardManager (real impl → Orchestra via unisdk) └── SimulatedDataManager / SimulatedDashboardManager (in-memory, for tests) ``` Three properties define the design: 1. **Pure primitives.** Neither manager has `ask`/`update` LLM tool loops. From [`base.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/base.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. 2. **Docstrings are the API docs.** The `@abstractmethod` docstrings on the base classes are what the Actor (an LLM) reads to learn the API — concrete classes inherit them via `functools.wraps`. If you change behavior, the docstring *is* the interface; keep it truthful. 3. **Contract-first discovery.** The primitives registry ([`unify/function_manager/primitives/registry.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/registry.py)) introspects `@abstractmethod` definitions on the base class to decide what the Actor sees as `primitives.data.*` and `primitives.dashboards.*`. Add an abstract method with a docstring and it becomes an Actor-callable primitive with no registry edits. Both managers are synchronous internally, listed in `_SYNC_MANAGERS` in [`unify/function_manager/primitives/runtime.py`](https://github.com/unifyai/unify/blob/main/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_DASHBOARD_IMPL` (`"real"` | `"simulated"`), read by `DataSettings` and `DashboardSettings` and resolved through `ManagerRegistry` — [`unify/manager_registry.py`](https://github.com/unifyai/unify/blob/main/unify/manager_registry.py). *** # DataManager ## Package anatomy | Path | Role | | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | | [`base.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/base.py) | `BaseDataManager` — the abstract contract, 26 methods, all docstrings | | [`data_manager.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/data_manager.py) | `DataManager` — context resolution, destination routing, federated fan-out; delegates to `ops/` | | [`simulated.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/simulated.py) | `SimulatedDataManager` — in-memory drop-in for tests | | [`ops/`](https://github.com/unifyai/unify/tree/main/unify/data_manager/ops) | Backend implementations (`*_impl` functions) calling `unisdk` | | [`types/`](https://github.com/unifyai/unify/tree/main/unify/data_manager/types) | Pydantic models: `ColumnInfo`, `TableSchema`, `TableDescription`, `IngestResult`, … | | [`utils/pipeline.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/utils/pipeline.py) | Generic DAG executor used by ingest | | [`settings.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/settings.py) | `DataSettings` (`UNITY_DATA_IMPL`) | 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: | Family | Methods | | ----------------- | ----------------------------------------------------------------------------------------------------------- | | Table schema | `create_table`, `describe_table`, `get_columns`, `get_table`, `list_tables`, `delete_table`, `rename_table` | | Column schema | `create_column`, `delete_column`, `rename_column`, `create_derived_column` | | Query | `filter`, `search`, `reduce` | | Join | `join_tables`, `filter_join`, `reduce_join`, `search_join`, `filter_multi_join`, `search_multi_join` | | Mutation & ingest | `insert_rows`, `update_rows`, `delete_rows`, `ingest`, `ensure_vector_column`, `vectorize_rows` | 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`](https://github.com/unifyai/unify/blob/main/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 like `Data/Sales/Monthly`) and each **row** is a **log event** in that context, written and read through [`unisdk`](https://github.com/unifyai/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/`, `Dashboards/`, …), 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 through `ContextRegistry.write_root(self, "Data", destination=...)` — `"personal"` (default) or `"team:"`, the latter landing under `Teams/{id}/Data/...` and requiring live team membership. * `_resolve_contexts_for_read`: for Data-owned, non-exact paths, fans out across `ContextRegistry.read_roots(...)` — the personal root **plus every accessible team root** — merging results via `federated_filter`, `federated_ranked_search`, and `federated_reduce` from [`unify/common/federated_search.py`](https://github.com/unifyai/unify/blob/main/unify/common/federated_search.py). Destination scopes: write_root routes each write to exactly one destination — the personal root or a team root, each holding Data and Dashboards contexts — while read_roots federates reads across personal plus all teams; data_scope additionally lets a personal tile read team data. Destination scopes: write_root routes each write to exactly one destination — the personal root or a team root, each holding Data and Dashboards contexts — while read_roots federates reads across personal plus all teams; data_scope additionally lets a personal tile read team data. `ContextRegistry` ([`unify/common/context_registry.py`](https://github.com/unifyai/unify/blob/main/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`](https://github.com/unifyai/unify/blob/main/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`](https://github.com/unifyai/unify/blob/main/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_`) 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_tables` **materializes** a joined table into a destination context (`unisdk.join_logs`) — use when the join result is itself a dataset. * `filter_join` / `reduce_join` are **ephemeral, fused** queries (`unisdk.join_query`) — a single server round-trip returning rows or aggregates, no temp context. `search_join` joins into a temp context, searches semantically, and cleans up. The `*_multi_join` variants chain steps, with `$prev` referencing the previous step's result. Join expressions namespace columns by full context path (`"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`](https://github.com/unifyai/unify/blob/main/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`](https://github.com/unifyai/unify/blob/main/unify/data_manager/ops/ingest_ops.py) builds a `TaskGraph` and hands it to `PipelineExecutor` ([`utils/pipeline.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/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): 1. **Type prescan** — [`ops/type_prescan.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/ops/type_prescan.py) infers column types from a stratified sample (`prescan_column_types` → `TypeMap`) and `coerce_batch` cleans each chunk (empty strings → `None`, type mismatches → `None`, tallied in `CoercionStats`). 2. **Create + chunked insert** — table created if needed, rows inserted in chunks (default 1000), serialized when `auto_counting` demands it. 3. **Optional embedding** — vector columns ensured and backfilled, batched. 4. **Post-ingest derived columns** — `PostIngestConfig` rules (`ExplicitDerivedColumn` with an `equation` like `"{unit_price} * {quantity}"`, or `AutoDerivedColumn` by source type). The whole run returns an `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_rows` is implemented as **delete + re-insert** in [`ops/mutation_ops.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/ops/mutation_ops.py) — not an atomic field update. * `update_rows` / `delete_rows` **require a filter** (or explicit `log_ids`); destructive table ops require `dangerous_ok=True`. * Writes into shared team contexts strip authorship fields via `is_shared_authored_context` ([`unify/common/authorship.py`](https://github.com/unifyai/unify/blob/main/unify/common/authorship.py)). * `describe_table` deliberately omits `row_count` (expensive); use `reduce(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/`](https://github.com/unifyai/unify/tree/main/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 DashboardManager. *** # DashboardManager (soon CanvasManager) ## Design in one paragraph The Actor generates **arbitrary HTML** — Plotly, D3, Chart.js from a CDN, or hand-rolled markup — and hands it to `create_tile`. The manager stores the tile as a row in a `Dashboards/Tiles` context, mints a 12-character shareable token, and (for live tiles) stores **declarative data bindings** plus an `on_data` JavaScript callback. The Console renders the HTML in a sandboxed iframe and executes the bindings at render time, so tiles read fresh data on every view. From [`base.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/base.py): *"The actor should **always** use DashboardManager for visualizations"* — it replaced the old plotting paths entirely. ## Package anatomy | Path | Role | | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | [`base.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/base.py) | `BaseDashboardManager` — 10-method contract (tile + dashboard CRUD), all docstrings | | [`dashboard_manager.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/dashboard_manager.py) | `DashboardManager` — destination routing, binding pipeline, token lifecycle | | [`simulated.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/simulated.py) | `SimulatedDashboardManager` — in-memory, sequential `sim_tile_0001` tokens | | [`types/tile.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/types/tile.py) | Binding classes, `TileRecordRow`/`TileRecord`/`TileResult`, `DASHBOARD_BRIDGE_MAX_ROW_LIMIT` | | [`types/dashboard.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/types/dashboard.py) | `TilePosition` (12-column grid), `DashboardRecordRow`/`DashboardRecord`/`DashboardResult` | | [`ops/tile_ops.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/ops/tile_ops.py) | Binding validation, alias assignment, context resolution, serialization | | [`ops/dashboard_ops.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/ops/dashboard_ops.py) | Layout serialize/deserialize | | [`ops/token_ops.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/ops/token_ops.py) | `generate_token`, `register_token`, `delete_token` | Persistence is notable: `DashboardManager` has **no storage code of its own** — tile and layout rows are written through `ManagerRegistry.get_data_manager()` (`insert_rows`, `filter`, `update_rows`, `delete_rows`) into two registered contexts, `Dashboards/Tiles` and `Dashboards/Layouts`. The dashboard layer is a client of the data layer. ## The binding type system Live tiles declare their data needs as a discriminated union of four Pydantic models (discriminator: `operation`), mirroring the DataManager query families: | Binding | Mirrors | Returns to `on_data` | | ------------------- | ------------- | ----------------------------- | | `FilterBinding` | `filter` | `Array` (rows) | | `ReduceBinding` | `reduce` | scalar or `{group: value}` | | `JoinBinding` | `filter_join` | `Array` (joined rows) | | `JoinReduceBinding` | `reduce_join` | scalar or `{group: value}` | Row-returning bindings are capped by `DASHBOARD_BRIDGE_MAX_ROW_LIMIT = 1000` — beyond that, aggregate with a reduce binding instead. Each binding carries an `alias` (a valid JS identifier, auto-derived from the context path's last segment by `_alias_from_context` when omitted), and `serialize_bindings` stores the whole list as compact JSON in the tile row's `data_bindings_json` field. The **`on_data` contract**: the Actor supplies a plain JS body (no function wrapper, no return). The Console wraps and invokes it as `(function(data){ })(results)`, where `results` is keyed by alias — `data.orders`, `data.revenue_by_region`. A guard in `validate_on_data` warns if the script contains `UnifyData.` calls: the bridge invocation is generated by the Console from the serialized bindings; *the Actor never writes bridge API calls*. ## Life of a live tile Live tile lifecycle: the Actor calls create_tile with HTML, data bindings, and on_data; DashboardManager validates and dry-runs the bindings, stores the tile row and registers its token; the Console TileViewer renders the iframe and injects the UnifyData bridge, which runs live filter/reduce/join queries against the Orchestra bridge at render time; results are handed to on_data so the chart updates with fresh data. Live tile lifecycle: the Actor calls create_tile with HTML, data bindings, and on_data; DashboardManager validates and dry-runs the bindings, stores the tile row and registers its token; the Console TileViewer renders the iframe and injects the UnifyData bridge, which runs live filter/reduce/join queries against the Orchestra bridge at render time; results are handed to on_data so the chart updates with fresh data. The `create_tile` pipeline in `DashboardManager`, step by step: 1. **Resolve the write root** — `ContextRegistry.write_root(self, "Dashboards/Tiles", destination=...)`. 2. **Mint the token** — `generate_token()` is `secrets.token_urlsafe(9)[:12]`. 3. **Validate bindings** — `validate_data_bindings` → `validate_on_data` → `ensure_binding_aliases`. 4. **Resolve the binding root** — `_data_binding_root()` honors `data_scope`: `"dashboard"` (default) inherits the tile's own destination root; `"team:"` pins bindings to a team root, validated against `SESSION_DETAILS.team_ids`. 5. **Resolve binding contexts** — `resolve_binding_contexts` turns Actor-relative paths into fully-qualified ones (and rewrites join expressions accordingly) so the stored binding is unambiguous. 6. **Dry-run every binding** — `verify_data_bindings` executes each binding through the live `DataManager` (`filter` with `limit=5`, `reduce`, `filter_join` with `result_limit=5`, `reduce_join`). A tile with a broken query never gets stored; you get a `TileResult` carrying the error. 7. **Store + register** — `build_tile_record_row` → `dm.insert_rows(...)`; then `register_token(token, "tile", context, project)` posts to Orchestra's token registry, and the returned `TileResult` carries the shareable `{CONSOLE_URL}/tile/view/{token}` URL. **Render time** (outside this repo, but essential context): the Console resolves the token, fetches the tile row, and renders `html_content` in a sandboxed iframe. For live tiles it injects a `UnifyData` bridge and an auto-exec script generated from `data_bindings_json`; the bridge `postMessage`s each query to the parent page, which proxies to Orchestra's admin bridge endpoints (filter / reduce / join / join-reduce — the same semantics as the DataManager methods, executed server-side under the tile creator's identity). Results come back keyed by alias and your `on_data` body runs. The net effect: **the queries you validated at create time are exactly the queries that run at render time.** **Static (baked-in) tiles** skip all of this: self-contained HTML with data embedded (e.g. Plotly's `fig.to_html(include_plotlyjs='cdn')`), no bindings, no bridge. Appropriate only for small, genuinely static snapshots. ## Dashboards and the grid A dashboard is a stored layout over tile tokens: `create_dashboard` takes `TilePosition` entries — `tile_token`, `x` (0–11), `y`, `w` (1–12), `h`, with `x + w ≤ 12` enforced — serialized to JSON in the `Dashboards/Layouts` row, plus its own token and share URL. Lifecycles are deliberately **independent**: deleting a dashboard leaves its tiles intact; deleting a tile doesn't rewrite layouts that reference it (they render a broken reference until updated). `update_tile` and `update_dashboard` preserve tokens — URLs never churn. Partial-update semantics: `None` preserves a field; `on_data=""` clears the script; a fresh empty `data_bindings=[]` drops live mode and resets `data_scope`; changing `data_scope` requires supplying fresh bindings in the same call. ## `destination` vs `data_scope` These are orthogonal, and the distinction is the most common point of confusion: * **`destination`** — where the tile/dashboard **row lives** (`"personal"` or `"team:"`), which controls who sees it in listings. * **`data_scope`** — which root the tile's **bindings read from** (`"dashboard"` = inherit the destination root, or an explicit `"team:"`). They can intentionally differ: a *personal* watch tile whose bindings read *team* operations data (`destination="personal"`, `data_scope="team:8"`) — covered by [`tests/dashboard_manager/test_tile_data_scope.py`](https://github.com/unifyai/unify/blob/main/tests/dashboard_manager/test_tile_data_scope.py). Reads across the board use `ContextRegistry.read_roots` fan-out, so `list_tiles` returns personal plus all accessible team tiles; updates and deletes must name the right `destination` or the row is simply not found. ## Extending **A new binding type** touches the full stack — in this repo: a new model with a unique `operation` literal added to the `DataBinding` union in `types/tile.py`; handling in `_contexts_for_binding`, `resolve_binding_contexts`, and `verify_data_bindings` (with a real DataManager dry-run) in `ops/tile_ops.py`; and documentation in the `create_tile` docstring (remember: docstrings are the Actor's API). The Console and Orchestra then need the matching bridge operation, proxy route, and admin endpoint. **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. ## Where to start reading | If you want to understand… | Start here | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | The full data API surface | [`data_manager/base.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/base.py) — read the docstrings; they're the spec | | Scope & destinations | [`common/context_registry.py`](https://github.com/unifyai/unify/blob/main/unify/common/context_registry.py) + [`data_manager/data_manager.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/data_manager.py) resolution helpers | | Multi-root reads | [`common/federated_search.py`](https://github.com/unifyai/unify/blob/main/unify/common/federated_search.py) | | Bulk loading | [`data_manager/ops/ingest_ops.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/ops/ingest_ops.py) + [`utils/pipeline.py`](https://github.com/unifyai/unify/blob/main/unify/data_manager/utils/pipeline.py) | | The tile/binding model | [`dashboard_manager/types/tile.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/types/tile.py) | | Binding validation | [`dashboard_manager/ops/tile_ops.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/ops/tile_ops.py) | | Actor-facing exposure | [`function_manager/primitives/registry.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/registry.py) + [`runtime.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/runtime.py) | | Worked Actor examples | [`actor/prompt_examples.py`](https://github.com/unifyai/unify/blob/main/unify/actor/prompt_examples.py) | | Behavioral guarantees | [`tests/data_manager/`](https://github.com/unifyai/unify/tree/main/tests/data_manager) + [`tests/dashboard_manager/`](https://github.com/unifyai/unify/tree/main/tests/dashboard_manager) | # Overview Source: https://docs.unify.ai/canvas/overview A live visual workspace your assistant builds for you Some answers don't want to be a paragraph. Numbers that change daily, trends worth watching, tables you'd otherwise rebuild in a spreadsheet every week — these want to be *live views* you can glance at. **Canvas** is where your assistant builds them. In the Console this lives in two dedicated panes on your assistant's page: **Dashboards** (the views) and **Data** (the tables underneath), each covered in depth in this section. The area is being renamed to **Canvas** — these docs use the new name. ## Two building blocks Everything on the canvas is built from two simple, very flexible pieces: Structured tables your assistant creates and maintains while it works — numbers pulled from an API, results of a computation, records extracted from files. Organized like folders, browsable any time. Live visual tiles built on top of that data — charts, tables, KPI cards, or any custom view — arranged into dashboards that refresh with the data underneath. The separation is the point: data collection and presentation are independent. The same table can feed three different views; a dashboard stays live as its tables grow; and either can change without rebuilding the other. ## You ask — it builds There is no dashboard editor to learn and no query language to write. You describe what you want to see, and your assistant builds it: * *"Plot repairs by category."* * *"Create a dashboard with our sales KPIs and a revenue trend chart."* * *"Track response times from our status API and chart the last 7 days."* * *"Show me open work orders per operative, updated live."* Your assistant creates or reuses the underlying tables, builds the tiles, arranges the dashboard, and drops a link in chat. From then on it's on your canvas, staying current. ## Why this is more than charts Because your assistant controls both the data *and* the view, the canvas can do things a normal dashboard tool can't do without an engineering project: * **It can collect what doesn't exist yet.** No data feed? Your assistant can call an API [on a schedule](/tasks/overview), extract numbers from the files you email it, or compute the metric itself — and store the results as a table. * **It can combine anything.** Two tables from different sources can be joined and aggregated into a single view — orders against refunds, pipeline against calendar. * **It can derive new columns.** "Add a total column that's unit price times quantity" is a sentence, not a formula bar. * **Views are unlimited in form.** Tiles aren't limited to a preset chart menu — bar, line, scatter, maps, KPI cards, styled tables, or fully custom layouts, whatever presents the data best. ## What's in this section What your assistant stores, how tables are organized, and browsing them in the Console. Tiles, layouts, live data, sharing, and the viewer in the Console. Worked examples — API monitoring, KPI boards, combined sources. # Recipes Source: https://docs.unify.ai/canvas/recipes Worked examples of the data + dashboard combination The building blocks — [tables](/canvas/data), [tiles](/canvas/dashboards), and [scheduled tasks](/tasks/overview) — are deliberately simple, and they compose. Here are patterns that come up again and again, each just a conversation away. ## Monitor an external service **The ask:** > "Every 15 minutes, check our status API. Log the response time and > whether it was healthy. Give me a dashboard with uptime over the last > week and a chart of response times — and text me if it's down twice in a > row." **What your assistant sets up:** a recurring task that calls the API and appends a row per check to a table like `Data/StatusAPI/checks`; a live dashboard with an uptime KPI card and a response-time chart reading from that table; and an alert rule as part of the task. The table quietly accumulates history — a month later, "how did latency trend after the release?" is answerable from data you already have. ## A morning KPI board **The ask:** > "Build me a board I can check with coffee: yesterday's orders and > revenue, the week-over-week trend, and the top five products. Refresh it > from the store API every morning at 6." **What it sets up:** a scheduled pull into `Data/Store/daily_sales`, plus a dashboard of KPI cards and a trend chart. Because tiles are live, the 6am task run is all it takes for the 8am glance to be current. ## Combine sources into one view **The ask:** > "Pull outstanding invoices from the accounts spreadsheet I email you each > week, and payments from the CRM. Join them and show me who actually owes > what — table plus a chart of overdue balances by client." **What it sets up:** two tables fed from different sources — one extracted from your emailed spreadsheet, one ingested from a [connected app](/integrations/overview) — joined into a single live view. Neither source system ever needed to know about the other. ## Keep intermediate work, don't redo it **The ask:** > "That customer-scoring analysis you just ran — keep the scored list > somewhere permanent and put the distribution on my dashboard. We'll want > to re-score monthly." **What it sets up:** the computation's result becomes a durable table rather than a throwaway answer, a tile visualizes it, and a monthly task re-runs the scoring. Combined with [learning](/learning/overview), the scoring method itself is saved too — so month two runs exactly like month one. ## Derived and combined variables **The ask:** > "In the orders table, add a margin column — price minus cost — and show > me average margin by product category as a bar chart." **What it sets up:** a derived column computed from existing ones, and an aggregation tile grouped by category. New columns can build on other derived columns, so composite metrics ("margin per unit shipped, indexed to January") are described in sentences, not formulas. *** The pattern behind every recipe is the same: **get the data flowing into a table, then put views on top.** If you're not sure whether something is possible, describe the outcome you want and let your assistant work out the plumbing — proposing a plan first for anything involved. # API messages Source: https://docs.unify.ai/communication/api Talk to your assistant from your own code Every other channel puts a person at one end — a phone, an inbox, a chat window. This one puts your code there instead. Send your assistant a message with an HTTP request, poll for its reply, and wire it into whatever you're building. No browser, no phone number, no connected account — just your API key. It's the same assistant either way. Something you ask for over the API is something you can follow up on in [Chat](/communication/console-chat) an hour later, and it'll know what you mean. ## What you need * **Your API key** and **your assistant's ID**, both from the [Console](https://console.unify.ai). * The base URL: `https://api.unify.ai/v0` * A Bearer token header on every request: ``` Authorization: Bearer YOUR_API_KEY ``` Assistants running in a [local deployment](/local-deployment/overview) don't receive API messages — this channel is for assistants hosted by Unify. ## Sending a message `POST /messages` with the assistant's ID and your message: ```bash theme={null} curl -X POST https://api.unify.ai/v0/messages \ -H "Authorization: Bearer $UNIFY_KEY" \ -H "Content-Type: application/json" \ -d '{ "assistant_id": , "message": "Add milk to my shopping list." }' ``` You get back a `message_id` straight away, before your assistant has done anything with it: ```json theme={null} { "info": { "message_id": "msg_abc123", "assistant_id": 42, "message": "Add milk to my shopping list.", "status": "processing", "response": null, "tags": [], "attachments": [], "response_tags": null, "response_attachments": null, "created_at": "2026-08-10T12:00:00Z", "completed_at": null } } ``` ## Polling for the reply Your assistant works on the message in the background — it might answer in a second, or go off and actually do the thing first. Poll `GET /messages/{message_id}` until `status` flips from `processing` to `completed`: ```bash theme={null} curl https://api.unify.ai/v0/messages/msg_abc123 \ -H "Authorization: Bearer $UNIFY_KEY" ``` ```json theme={null} { "info": { "message_id": "msg_abc123", "assistant_id": 42, "message": "Add milk to my shopping list.", "status": "completed", "response": "Done! I've added milk to your shopping list.", "tags": [], "attachments": [], "response_tags": [], "response_attachments": null, "created_at": "2026-08-10T12:00:00Z", "completed_at": "2026-08-10T12:00:05Z" } } ``` `response` can come back `null` on a completed message. That's not an error — your assistant decides whether a reply is warranted, the same way it does on any other channel, and sometimes doing the task quietly is the right answer. ## From Python The SDK wraps both calls: ```python theme={null} import unisdk status = unisdk.agent.send_message( assistant_id=, message="Add milk to my shopping list.", ) status = unisdk.agent.get_message_status(status["message_id"]) print(status["status"], status["response"]) ``` ## Sending files Upload each file first, then reference it in the message. ```bash theme={null} curl -X POST https://api.unify.ai/v0/messages/attachments \ -H "Authorization: Bearer $UNIFY_KEY" \ -F "file=@report.pdf" \ -F "assistant_id=" ``` You get back the file's metadata: ```json theme={null} { "id": "att_xyz789", "filename": "report.pdf", "gs_url": "gs://bucket/path/report.pdf", "content_type": "application/pdf", "size_bytes": 204800 } ``` ```bash theme={null} curl -X POST https://api.unify.ai/v0/messages \ -H "Authorization: Bearer $UNIFY_KEY" \ -H "Content-Type: application/json" \ -d '{ "assistant_id": , "message": "Please summarise this report.", "attachments": [ { "id": "att_xyz789", "filename": "report.pdf", "gs_url": "gs://bucket/path/report.pdf" } ] }' ``` Files are capped at **25 MB** each. Your assistant can send files back too — they arrive as `response_attachments` on the completed message, each with a download URL. ## Tags Tags are arbitrary strings you can hang off a message. Your assistant treats them as opaque routing labels — it doesn't read anything into them — and echoes them back on its reply as `response_tags`. ```bash theme={null} curl -X POST https://api.unify.ai/v0/messages \ -H "Authorization: Bearer $UNIFY_KEY" \ -H "Content-Type: application/json" \ -d '{ "assistant_id": , "message": "Summarise today'\''s sales figures.", "tags": ["source:slack", "channel:#analytics"] }' ``` That's what makes them useful for bridging: if you're relaying messages from somewhere else, tag the inbound message with wherever it came from and the reply tells you where to send it back. Tags are how you keep one assistant serving several surfaces at once without losing track of which reply belongs to which request. # How your assistant communicates Source: https://docs.unify.ai/communication/behavior When it speaks up, when it stays quiet, and how to steer it Your assistant is designed to communicate like a considerate colleague — not like an app pushing notifications. Here's what to expect, and how to shape it. ## It answers where you ask Message your assistant on any channel and it answers there. Ask it over SMS to email you a document, and you'll get a text confirming it plus the email in your inbox. The conversation is one continuous thread in your assistant's memory, whatever mix of channels it spans. ## It knows when to stay quiet After answering you, your assistant doesn't keep talking to fill the silence. It won't send you play-by-play updates unless you want them, and in group settings — Slack channels, meetings, calls with several people — it speaks when addressed or when it genuinely has something to add. In a room with several AI teammates — a [group or team chat](/communication/group-chats), a Slack or Teams channel — exactly one of them should answer any given message. Name the teammate you want and the rest stay out of it; name nobody and whoever the work belongs to picks it up. Assistants also mostly don't reply to *each other*: acknowledging a teammate is noise, and two assistants trading acknowledgements is a loop that costs real money. ## It can reach out first Your assistant isn't purely reactive: * **Task updates** — when something you asked for is done (or blocked), it tells you on an appropriate channel. * **Scheduled work** — [tasks](/tasks/overview) you've scheduled can end with a message, an email, or a call at the time you chose. * **Gentle follow-ups** — if you've been away from the platform for a while, your T-W1N sends a short check-in email. At most three per quiet spell, and then it leaves you alone until you're active again. This re-engagement is specific to your T-W1N; hired teammates don't do it. Ask it to stop and it stops, until you ask it to resume. * **Ringing you** — when a live conversation beats a wall of text, it can ring you on [Unify Meet](/communication/unify-meet), and falls back to chat if you don't pick up. ## On calls, it behaves like a person * **Short turns.** It says one thing at a time instead of monologuing, and keeps phone answers conversational. * **Interruptible.** Talk over it and it stops and listens. * **Honest thinking time.** When your question needs real work, you'll hear a brief "one moment" while it works, rather than silence or waffle. * **Aware of the room.** It can distinguish speakers and won't jump into a side conversation it overhears. ## It talks to other people on your behalf Your assistant can message and call people other than you — chasing invoices, coordinating with a contractor, replying to a client email thread. Every person it deals with is a [**contact**](/communication/contacts), with their own channels and preferences: * It writes to each contact **in their language**. * Contacts can be marked **do-not-contact**, and your assistant will never message them — it can also hold a note explaining why. * When someone new gets in touch on a channel, your assistant treats unknown senders with appropriate caution rather than assuming who they are. ## Steering it The most powerful setting is simply telling your assistant what you want: "always text me instead of emailing", "never call after 6pm", "stop the follow-up emails", "keep updates to once a day". It remembers your preferences and applies them — see [how learning works](/learning/overview). # Console chat Source: https://docs.unify.ai/communication/console-chat Message your assistant right in the Console The **Chat** tab is the first thing you see on your assistant's page in the [Console](https://console.unify.ai), and the fastest way to talk to it — no setup, no phone numbers, no connected accounts. As the tab itself puts it: *"messages, voice notes, files, and screen share in one thread."* ## Composing * **Type and send** — Enter sends, Shift+Enter adds a line break. * **Speak instead of type** — hold the mic to record a voice note. Your speech is **transcribed into the composer**, so you review the text before sending rather than firing off raw audio. * **Start a call** — click your assistant's avatar to ring it on [Unify Meet](/communication/unify-meet); screen sharing lives in the call, one click away from chat. ## Attachments Drop files straight onto the composer, pick them from the paperclip menu, or use **Take a Photo** to capture a document with your camera. Your assistant reads what you send and works with it directly. * Most everyday formats are accepted — documents, spreadsheets, images, audio, video, archives, data files — up to **32 MB** each. (Executable files are blocked for safety.) * Each attachment shows its upload progress and can be cancelled before sending. ## Living in the thread * **Reactions** — react to your assistant's messages with emoji; it notices. * **Hear replies aloud** — if your assistant has a [voice](/communication/voice), each of its messages has a play button. * **Copy** any assistant message with one click. * **Call records inline** — voice calls appear in the thread as pills showing their duration; click one to read the **call transcript** without leaving chat. * **Interactive embeds** — links to [tiles, charts, tables, and dashboards](/canvas/dashboards) render as inline previews you can expand in place or pop into a new tab. ## Finding things The **Search conversation** control opens a full search over your history with this assistant — chat messages and calls together. Filter by source (**Chat** or **Call**), by attachment type (PDF, image, spreadsheet, code…), by sender (**Me** or the assistant), and by date range. **Go To Message** jumps you to the match in its original context, and **Jump to Present** brings you back to now. For history *beyond* this one thread — email, SMS, WhatsApp, and every other channel — see [Transcripts](/communication/recordings-and-transcripts). ## Beyond your own thread This page is your one-to-one thread with one assistant. In an organization the same composer also runs [group chats](/communication/group-chats) — rooms shared with colleagues, teams, and several assistants at once, where naming the teammate you want decides who answers. ## Always on, and honest when it isn't Chat is your assistant's home channel: updates, links, and deliverables land here even when the work started somewhere else, and unread messages show as badges on the assistant list and your browser tab. When something blocks the conversation, the composer says so plainly rather than failing silently — *"Chat unavailable, retrying connection…"* during a network hiccup, or *"Spending limit reached"* when a monthly cap has been hit (adjustable from the assistant's profile or the Usage page). Chat is the best place to ask your assistant *about* the platform itself. Questions like "how do I set up your email?" get an answer plus an offer to walk you through it on a screen-share call. # Contacts Source: https://docs.unify.ai/communication/contacts Everyone your assistant knows, and how it treats each of them Your assistant doesn't just exchange messages — it maintains relationships. Every person it deals with is a **contact**: who they are, how to reach them, what's been discussed, and how your assistant should behave toward them. The **Contacts** pane (in the Brain section of your assistant's page) is the directory of all of it — *"people your teammate remembers, with the context it keeps on each."* ## The directory Contacts appear as a searchable card grid. Each card gives you the essentials at a glance: name and job title, email, timezone, tags — and a small status dot showing whether your assistant **auto-responds** to this person or not. Search by name, job title, or email; filter by tags to slice the directory into groups you've defined ("suppliers", "board", "tenants"). Contacts marked as **system contacts** are the ones the platform maintains for you automatically — you, and (in an organization) your colleagues. ## Inside a contact Click any card for the full picture your assistant holds: * **Channels** — email, phone, WhatsApp, Discord, Slack — everywhere this person can be reached, which is also what determines [where your assistant can contact them](/communication/overview). * **Bio** — who this person is, in your assistant's own notes. * **Rolling summary** — a living digest of your assistant's dealings with them, kept current as conversations happen. * **Should respond** and **Response policy** — the behavioral controls: whether your assistant may message this person at all, and the standing instructions for how to engage them (tone, boundaries, topics to avoid, escalation rules). The response machinery is worth understanding — it's how a teammate that [can talk to anyone](/hiring/liaison) stays safe doing so. A contact with responses switched off is a hard line: your assistant cannot message them, whoever asks. Unknown senders start in exactly that state until vetted. ## Growing and editing The directory largely **builds itself**: people your assistant encounters become contacts, details get filled in as they're learned, and summaries update automatically. You can also curate directly: * **Add contact** creates one by hand; **Edit** in the detail drawer opens the full form — names, channels, bio, tags, response settings. * Or just tell your assistant: "add Dana from Acme, her email is…", "never discuss pricing with contractors", "stop responding to that recruiter." Instructions like these update the relevant contact's details and policy. ## Personal and shared An assistant on [teams](/teams/shared-context) sees team-shared contacts alongside its personal ones, merged into a single directory — so a specialist serving the repairs team works from the team's shared relationships, not a private copy. # Architecture Source: https://docs.unify.ai/communication/developers/architecture How communication actually works in the open-source unify repo Everything in the [Communication section](/communication/overview) is implemented in the open-source [`unifyai/unify`](https://github.com/unifyai/unify) repository. This developer sub-section explains how it *actually works* — the packages, classes, and contracts — for anyone reading, extending, or self-hosting the code. The communication stack is two cooperating pieces: 1. **The gateway** — [`unify/gateway/`](https://github.com/unifyai/unify/tree/main/unify/gateway) — a FastAPI application that is the HTTP edge between external providers (Twilio, Google, Microsoft, Slack, Discord, the Console) and the assistant runtime. It owns webhook parsing, route resolution, OAuth callbacks, and outbound provider calls. 2. **The conversation runtime** — [`unify/conversation_manager/`](https://github.com/unifyai/unify/tree/main/unify/conversation_manager) — the `ConversationManager` process that consumes normalized events, runs the reasoning loop (the "slow brain"), and sends outbound messages back through the gateway via [`unify/comms/primitives.py`](https://github.com/unifyai/unify/blob/main/unify/comms/primitives.py). unify.gateway architecture: providers feed adapters, which normalize to envelopes delivered through an EnvelopeSink to the ConversationManager; the ConversationManager sends outbound via channels unify.gateway architecture: providers feed adapters, which normalize to envelopes delivered through an EnvelopeSink to the ConversationManager; the ConversationManager sends outbound via channels ## The wire contract: envelopes Every inbound event that crosses the gateway → runtime boundary is wrapped in a `{thread, publish_timestamp, event}` **envelope**, defined in [`unify/gateway/envelopes.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/envelopes.py) (the full catalogue of `thread` keys lives in `KNOWN_THREADS` there). The `thread` is a routing key like `msg`, `whatsapp`, `email`, `unify_meet`, or `call_answered`; the `event` is a channel-specific payload. This single contract is what lets the same runtime code serve every channel. ## One codebase, two deployments A design goal stated in the [gateway README](https://github.com/unifyai/unify/blob/main/unify/gateway/README.md): local self-hosted and hosted SaaS deployments run **the same gateway code** — only the backend implementations differ. The seams are small protocol interfaces carried on a `GatewayContext` ([`unify/gateway/context.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/context.py)): | Protocol | Local default | Hosted implementation | | ------------------ | ------------------------------------------------------------------- | ------------------------------------------------------ | | `EnvelopeSink` | `HttpEnvelopeSink` — POSTs envelopes to the runtime's local ingress | Pub/Sub publish to a per-assistant topic | | `RuntimeActivator` | `LocalRuntimeActivator` (no-op; the runtime is already running) | `HostedRuntimeActivator` — wakes an idle assistant job | | `Storage` | `LocalDiskStorage` for attachments | Cloud storage | | `CredentialStore` | `EnvCredentialStore` — reads environment variables | Cloud secret backend | The app factory is `create_app()` in [`unify/gateway/app.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/app.py). The OSS path runs the stock app (`python -m unify.gateway serve`); the hosted platform constructs its own app via `create_app(extra_routers=..., extra_setup_hooks=...)` and mounts private pieces on top of the same factory. Envelope sinks live in [`unify/gateway/envelope_sink.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/envelope_sink.py), and ingress transports (the runtime's receiving side) in [`unify/gateway/ingress.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/ingress.py) and siblings (`ingress_pubsub.py`, `ingress_inmemory.py`). ## The full round trip Following one SMS end to end: 1. **Twilio → adapter.** Twilio POSTs to `twilio_sms_webhook` in [`unify/gateway/adapters/twilio.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/twilio.py). The signature is validated, the target assistant is resolved (via Orchestra's admin API), and the runtime is woken if idle. 2. **Adapter → envelope.** `publish_runtime_event()` in [`unify/gateway/adapters/common.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/common.py) wraps the payload in an envelope with `thread="msg"` and hands it to the `EnvelopeSink`. 3. **Envelope → typed event.** In the runtime, `CommsManager` ([`unify/conversation_manager/comms_manager.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/comms_manager.py)) maps the thread key through its `events_map` to a typed event — `SMSReceived` from [`unify/conversation_manager/events.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/events.py) — resolving the sender to a contact along the way. 4. **Event → handler → brain.** The event is published on the in-process broker; `ConversationManager.wait_for_events()` picks it up, the registered handler logs it to transcripts and updates live state, and a slow-brain turn is scheduled. Details in [The conversation runtime](/communication/developers/conversation-manager). 5. **Brain → outbound.** If the brain decides to reply, its `send_sms` tool delegates to `CommsPrimitives.send_sms`, which enforces the contact's `should_respond` gate and POSTs to the gateway's `/phone/send-text` channel endpoint — which calls Twilio. A matching `SMSSent` event is published so the reply lands in transcripts too. ## Package map | Package | Role | Key entry points | | ------------------------------------------------------------------------------------------------------ | ----------------------------------- | ------------------------------------------------------------------- | | [`unify/gateway/adapters/`](https://github.com/unifyai/unify/tree/main/unify/gateway/adapters) | Inbound webhook normalization | `twilio.py`, `google.py`, `microsoft.py`, `slack.py`, `internal.py` | | [`unify/gateway/channels/`](https://github.com/unifyai/unify/tree/main/unify/gateway/channels) | Outbound + admin channel APIs | one sub-package per channel | | [`unify/gateway/credentials/`](https://github.com/unifyai/unify/tree/main/unify/gateway/credentials) | Operator infrastructure credentials | `CredentialStore`, `EnvCredentialStore` | | [`unify/conversation_manager/`](https://github.com/unifyai/unify/tree/main/unify/conversation_manager) | The live runtime | `ConversationManager`, `events.py`, `domains/` | | [`unify/comms/`](https://github.com/unifyai/unify/tree/main/unify/comms) | Outbound send implementation | `CommsPrimitives` | | [`unify/transcript_manager/`](https://github.com/unifyai/unify/tree/main/unify/transcript_manager) | Durable conversation record | `TranscriptManager` | ## Reading order Channels vs adapters, per-channel modules, credentials, and how to add a channel. Events, mediums, the slow-brain loop, outbound sends, and transcripts. The dual-brain call architecture — LiveKit, fillers, barge-in, and hang-up semantics. # The conversation runtime Source: https://docs.unify.ai/communication/developers/conversation-manager Events, mediums, the slow-brain loop, and outbound sends The `ConversationManager` ([`unify/conversation_manager/conversation_manager.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/conversation_manager.py)) is the live "front office": a singleton that holds in-memory conversation state, consumes events, runs the reasoning loop, and delegates outbound I/O. Its package README describes it as the layer that interfaces with users while delegating complex reasoning to the `Actor`. Inbound event lifecycle: envelope → CommsManager → EventBroker → ConversationManager.wait_for_events → side effects → slow brain turn → comms tools / act / wait Inbound event lifecycle: envelope → CommsManager → EventBroker → ConversationManager.wait_for_events → side effects → slow brain turn → comms tools / act / wait ## The event model Everything the runtime reacts to is a typed dataclass extending `Event` ([`unify/conversation_manager/events.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/events.py)). Subclasses auto-register via `__init_subclass__`, serialize with `to_json()`/`from_dict()`, and carry behavioral flags — `loggable` (persist to transcripts), `prominent`, and `suppress_slow_brain_wake` (don't schedule a brain turn for this event). The catalogue is broad and symmetric: for most channels there's a `*Received` and a `*Sent` class (`SMSReceived`/`SMSSent`, `EmailReceived`/`EmailSent`, `UnifyMessageReceived`/`UnifyMessageSent`, and — for the org-installed Teams app — separate DM and channel pairs `MsTeamsBotMessageReceived`/`MsTeamsBotMessageSent` and `MsTeamsBotChannelMessageReceived`/`MsTeamsBotChannelMessageSent`…), plus call-lifecycle events (`PhoneCallReceived`, `PhoneCallAnswered`, `PhoneCallEnded`, `UnifyMeetStarted`…), per-utterance voice events (`InboundPhoneUtterance`, `OutboundUnifyMeetUtterance`…), reactions, voice-control events (`FastBrainNotification`, `VoiceInterrupt`), and lifecycle events (`StartupEvent`, `TaskDue`, `PreHireMessage`). Two mapping layers connect the world to handlers: 1. **Thread → event class.** `CommsManager.events_map` in [`comms_manager.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/comms_manager.py) turns an envelope's `thread` key into an event instance, resolving the sender to a contact via `MEDIUM_TO_CONTACT_FIELD` and publishing to the in-process broker on `app:comms:*` topics. 2. **Event class → handler.** `EventHandler` in [`domains/event_handlers.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/event_handlers.py) is a registry: handlers are declared with `@EventHandler.register(EventClass)` and dispatched by `EventHandler.handle_event(event, cm)`. A typical text-message handler logs to transcripts, updates the live `ContactIndex`, pushes a `NotificationBar` entry, and requests a brain run. ## Mediums and modes [`cm_types/medium.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/cm_types/medium.py) defines the `Medium` enum — the single source of truth for channel types (`UNIFY_MESSAGE`, `EMAIL`, `SMS_MESSAGE`, `WHATSAPP_MESSAGE`, `WHATSAPP_CALL`, `PHONE_CALL`, `UNIFY_MEET`, `GOOGLE_MEET`, `TEAMS_MEET`, `API_MESSAGE`, Slack/Discord variants, and both the delegated-Graph (`TEAMS_MESSAGE`, `TEAMS_CHANNEL_MESSAGE`) and org-installed bot (`MS_TEAMS_BOT_MESSAGE`, `MS_TEAMS_BOT_CHANNEL_MESSAGE`) Teams mediums…). The two org-installed bot send tools are gated behind the assistant's `has_ms_teams_bot` capability. Each medium registers a `MediumInfo` with a **mode**: `TEXT`, `CALL`, or `MEET` ([`cm_types/mode.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/cm_types/mode.py)). Mode is what gates behavior — voice tools like `guide_voice_agent` only exist in voice sessions, and transcript rows pick their exchange from call state in voice modes. `MEDIUM_TO_CONTACT_FIELD` maps external mediums to the contact field used for identity resolution (`phone_number`, `whatsapp_number`, `email_address`, `slack_user_id`…). ## The event loop and slow-brain scheduling `ConversationManager.wait_for_events()` subscribes to the in-memory broker (patterns `app:comms:*`, `app:actor:*`, and friends), deserializes each message back into an `Event`, and dispatches through `EventHandler`. Handlers never call the LLM directly — they call `request_llm_run()`, which queues a request; `flush_llm_requests()` coalesces the queue and submits through a `Debouncer` ([`domains/utils.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/utils.py)) with queue-of-2 semantics: at most one running turn plus one pending, with user-origin requests outranking background ones. A single turn (`_run_llm`) is assembled from pure parts: * `Renderer.render_state()` ([`domains/renderer.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/renderer.py)) snapshots the conversation into prompt text (and computes incremental diffs used when steering in-flight work); * `build_brain_spec()` ([`domains/brain.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/brain.py)) materializes the full inputs for one run — deliberately a small explicit structure so execution strategy can change without touching prompt construction; * the tool surface merges read-only inspection tools (`ConversationManagerBrainTools` in [`domains/brain_tools.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/brain_tools.py)), action tools (`ConversationManagerBrainActionTools` in [`domains/brain_action_tools.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/brain_action_tools.py) — every `send_*`, `make_call`, `start_unify_meet`, `act`, `wait`, `guide_voice_agent`…), and **dynamic steering tools** generated per in-flight action handle ([`task_actions.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/task_actions.py): `ask`/`stop`/`interject`/`pause`/`resume` per handle). The turn executes as a single tool-decision call; choosing `wait` ends the turn silently, which is how the assistant's default "don't spam" behavior is implemented. ## The domains package The `domains/` directory is an ownership map — each module owns one concern: | Module | Owns | | ------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | [`comms_manager.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/comms_manager.py) | Ingress bridge: envelopes → typed events → broker | | [`event_handlers.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/event_handlers.py) | All inbound event side effects | | [`call_manager.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/call_manager.py) | Voice session lifecycle — see [Voice calls](/communication/developers/voice) | | [`brain.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/brain.py) / `brain_tools.py` / `brain_action_tools.py` | The slow-brain spec and tool surfaces | | [`renderer.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/renderer.py) | State → prompt snapshots and diffs | | [`contact_index.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/contact_index.py) | Live per-medium message threads | | [`notifications.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/notifications.py) | The `NotificationBar` surfaced in prompts | | [`task_execution.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/task_execution.py) | `TaskDue` wake-ups and live task execution | | [`proactive_speech.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/proactive_speech.py) | Breaking silence on calls | | [`comms_utils.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/comms_utils.py) | HTTP calls to the gateway's channel endpoints | | [`managers_utils.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/managers_utils.py) | Manager init and `log_message()` → transcripts | ## Outbound: CommsPrimitives [`unify/comms/primitives.py`](https://github.com/unifyai/unify/blob/main/unify/comms/primitives.py) is **the single implementation of assistant-owned outbound communication** — the same `CommsPrimitives` class serves both the live brain's action tools and `primitives.comms.*` inside Actor plans and offline task runs. Every send follows the same pipeline (using `send_sms` as the example): 1. **`_check_outbound_allowed(contact)`** — the hard `should_respond` gate. A contact with `should_respond=False` cannot be messaged, period; the method returns an explanatory error string the brain must surface. 2. **Detail resolution** — attach an inline phone/email to the contact if one was provided. 3. **HTTP to the gateway** — via [`comms_utils.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/comms_utils.py) (e.g. `POST {COMMS_URL}/phone/send-text`). 4. **Delivery proof** — on success, a `*Sent` event (`SMSSent`) is published; its handler writes the transcript row. This is why the prompt-level rule "an outbound message isn't real until its transcript row appears" holds — the row *is* the proof. Outbound events published by the brain's own sends set `suppress_slow_brain_wake` so the assistant isn't re-woken by its own confirmations. ### The API message round trip The [developer API channel](/communication/api) is the one send path that doesn't originate a conversation — it completes a caller that is already waiting, so it runs request/response rather than fire-and-forget: 1. **Inbound.** Orchestra's `POST /v0/messages` persists an `ApiMessage` row and dispatches it to the adapters, which publish an `api_message` envelope. `CommsManager.events_map` maps that thread to `ApiMessageReceived`, whose handler parks the `api_message_id` and the caller's tags on the `ConversationManager` as `_pending_api_message_id` / `_pending_api_message_tags`. 2. **Outbound.** `send_api_response` reads that pending id, so it is only meaningful while a call is in flight — with nothing pending it returns `{"status": "ok", "note": "no pending api message"}` rather than opening a new outbound conversation. It uploads any attachments, then `comms_utils.complete_api_message()` issues `PUT {ORCHESTRA_URL}/messages/{id}/complete`, which is what flips the caller's poll from `processing` to `completed`. 3. **Record.** An `api_message_sent` event writes the transcript row, anchored to the boss contact — the API exchange lands in the same durable history as every other medium (`Medium.API_MESSAGE`). Tags are opaque to the runtime; the prompt tells the brain to echo them back by default so the developer can route the reply on their own side. ## Transcripts [`unify/transcript_manager/transcript_manager.py`](https://github.com/unifyai/unify/blob/main/unify/transcript_manager/transcript_manager.py) owns the durable record: `Message` rows (medium, sender, receivers, content, attachments) grouped into `Exchange` threads ([`types/message.py`](https://github.com/unifyai/unify/blob/main/unify/transcript_manager/types/message.py), [`types/exchange.py`](https://github.com/unifyai/unify/blob/main/unify/transcript_manager/types/exchange.py)). The runtime writes through `managers_utils.log_message()`, which infers medium and role from the event class, resolves the exchange (from call state for voice), and calls `TranscriptManager.log_messages()`. Voice utterances are stored with call-relative timestamps. These are the same rows the Console's [Transcripts pane](/communication/recordings-and-transcripts) renders. ## Steering and interruptions Communication interacts with the [steering machinery](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/task_actions.py) in two directions. Downward: every in-flight `act(...)` is a steerable handle the brain can `ask`, `interject`, `pause`, `resume`, or `stop` via dynamically generated tools. Upward: when a user speaks while the slow brain is mid-turn, the input does **not** cancel the running turn. The `Debouncer` ([`domains/utils.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/utils.py)) holds one running turn plus at most one pending turn — a new submission replaces whatever was pending and starts when the current turn finishes. Preemption used to exist (a `SpeechUrgencyEvaluator` that classified the utterance and cancelled the running turn) and was removed: the queue of two is the whole mechanism. What makes interruption feel immediate on a call is the fast brain — barge-in and the interim turn — not turn cancellation; see [Voice calls](/communication/developers/voice). # The gateway Source: https://docs.unify.ai/communication/developers/gateway Channels, adapters, credentials, and the provider edge The gateway ([`unify/gateway/`](https://github.com/unifyai/unify/tree/main/unify/gateway)) is a single FastAPI application assembled by `create_app()` in [`app.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/app.py) and launched with `python -m unify.gateway serve` (the CLI in [`__main__.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/__main__.py) also provides `doctor`, `urls`, `setup`, `smoke`, and `wizard` subcommands). It runs as a **separate process** from the `ConversationManager`. ## Channels vs adapters The two sub-packages split along an ingress/egress line — but precisely: * **[`channels/`](https://github.com/unifyai/unify/tree/main/unify/gateway/channels)** hosts the **outbound and admin APIs**: sending messages, placing calls, provisioning numbers, managing watches and installs. Nearly all routes require the admin bearer token (`admin_auth_dependency` in [`common/auth.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/common/auth.py)). A few unauthenticated routes live here too where the provider calls back mid-flow (Twilio's TwiML and conference-status callbacks). * **[`adapters/`](https://github.com/unifyai/unify/tree/main/unify/gateway/adapters)** hosts **inbound normalization**: provider webhooks and internal Console routes, each validated (Twilio signatures, Slack signing secrets, OAuth state), resolved to a target assistant, and reduced to a `{thread, event}` envelope. Two deliberate exceptions: **Discord** inbound uses a persistent WebSocket (`GatewayConnection` in [`channels/discord/gateway.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/discord/gateway.py), supervised by `bot_manager.py`) rather than a webhook — DMs always route, guild messages only on @mention. And **social verification** ([`channels/social/views.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/social/views.py)) is a synchronous Twilio send that returns the code to the caller — it never publishes an envelope. ## Channel modules Each channel is a sub-package under `channels/` with a `views.py` exposing its router(s): | Channel | Highlights | External SDKs | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | [`phone/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/phone/views.py) | `send_call`, `send_text`, `create_phone_number` (buys a Twilio number, wires webhooks, creates the matching LiveKit SIP trunk), `dispatch_livekit_agent`, hang-up and conference teardown | `twilio`, `livekit.api` | | [`whatsapp/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/whatsapp/views.py) | `send` (freeform vs template by 24-hour window), `send_call` with call-permission handling, sender provisioning and pool assignment | Twilio WA, LiveKit SIP | | [`gmail/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/gmail/views.py) | `send_email` (threading headers), `watch_email` (Pub/Sub push watch), attachment fetch | `googleapiclient`, `google.oauth2` | | [`outlook/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/outlook/views.py) | Same shape over Microsoft Graph subscriptions | `msgraph` via [`common/graph.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/common/graph.py) | | [`email/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/email/views.py) | Provider-agnostic dispatcher — routes `send_email` to Gmail or Outlook based on how the assistant's mailbox is connected | — | | [`teams/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/teams/views.py) | Delegated Microsoft Graph Teams: chats, group chats, channels, watches, and meeting creation (`create_meeting.py`: instant links vs scheduled calendar events) | `msgraph` | | [`ms_teams_bot/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/ms_teams_bot/views.py) | Org-installed Bot Framework Teams app: pending-install / tenant-bind / install upsert, `send` (DM + channel/group), Bot Connector token minting from the bot's home tenant | Bot Framework Connector | | [`slack/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/slack/views.py) | Workspace install upsert, `send_slack_message`, user lookup by email | Slack Web API | | [`discord/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/discord/views.py) | `send_discord_message`, bot pool registration and status | Discord REST | | [`drive/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/drive/views.py) / [`sharepoint/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/sharepoint/views.py) | File browsing for the workspace file picker (not messaging) | Google Drive API, `msgraph` | | [`unillm/`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/unillm/views.py) | `chat_completions` proxy authenticated by *user* API key (the one channel not admin-authed) | `unillm` | ## Adapter modules | Adapter | Webhooks handled | Envelope threads produced | | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | [`twilio.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/twilio.py) | SMS, voice calls, call status, WhatsApp messages/reactions/calls/call status | `msg`, `call`, `call_answered`, `call_not_answered`, `whatsapp`, `whatsapp_reaction`, `whatsapp_call`, … | | [`google.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/google.py) | Gmail Pub/Sub push notifications, Google OAuth callback and revoke | `email` | | [`microsoft.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/microsoft.py) | Outlook and delegated-Teams Graph notifications, Microsoft OAuth callback, a shared `/microsoft/router` delegator | `email`, `teams_chat`, `teams_channel` | | [`ms_teams_bot.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/ms_teams_bot.py) | Bot Framework activity webhook for the org-installed Teams app; validates the inbound Bot Framework JWT ([`ms_teams_bot_auth.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/ms_teams_bot_auth.py)), resolves the assistant via the dispatcher (token addressing + coordinator fallback) | `ms_teams_bot` | | [`slack.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/slack.py) | Slack Events API, with a two-pass sender-identity resolution for coordinator routing and message dedup | `slack` | | [`internal.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/internal.py) | Console/runtime routes: chat messages, reactions, attachments, Unify Meet rings, system events, pre-hire chat logging, wake-ups | `unify_message`, `unify_meet`, `api_message`, `unity_system_event`, … | The shared inbound pipeline lives in [`adapters/common.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/common.py): resolve the assistant (`get_assistant`), resolve the channel route where pools are involved (`resolve_phone_route`, `resolve_whatsapp_route` — this is how shared numbers map `(pool number, sender)` to the right assistant), activate the runtime, then `publish_runtime_event()`. ## Credentials [`unify/gateway/credentials/`](https://github.com/unifyai/unify/tree/main/unify/gateway/credentials) holds **operator infrastructure credentials** — Twilio account, Slack signing secret, OAuth client IDs, LiveKit keys — behind the `CredentialStore` protocol (`base.py`), with `EnvCredentialStore` (`env.py`) as the OSS implementation reading environment variables. The package docstring draws the line explicitly: this is *distinct from* `unify.secret_manager`, which holds **assistant-owned user secrets** (per-assistant OAuth tokens like `GOOGLE_ACCESS_TOKEN` live in Orchestra assistant secrets, not here). ## Adding a channel: the seams The gateway is designed so a new channel touches a predictable set of files: 1. **Outbound** — a new sub-package under `channels/` with an admin-authenticated router; mount it via the prefix table in `create_app()`. 2. **Inbound** — a webhook handler under `adapters/` that validates, resolves the assistant, and publishes an envelope with a new `thread` key (register it in `KNOWN_THREADS` in [`envelopes.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/envelopes.py)). 3. **Runtime** — a typed event class in [`events.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/events.py), an entry in `CommsManager.events_map`, and an `@EventHandler.register` handler — covered in [the conversation runtime](/communication/developers/conversation-manager). 4. **Send path** — a method on `CommsPrimitives` plus its brain-tool wrapper. # Voice calls Source: https://docs.unify.ai/communication/developers/voice The dual-brain call architecture Live voice is where the architecture earns its keep: a call needs sub-second conversational reflexes *and* deep reasoning, and no single loop provides both. The runtime splits the job between two brains that share one conversation. Voice call dual-brain architecture: the caller connects through a LiveKit room to the fast brain (Assistant agent with STT/VAD/TTS), which exchanges utterance events and FastBrainNotifications with the slow brain (ConversationManager), supported by fillers/barge-in handling and the LivekitCallManager Voice call dual-brain architecture: the caller connects through a LiveKit room to the fast brain (Assistant agent with STT/VAD/TTS), which exchanges utterance events and FastBrainNotifications with the slow brain (ConversationManager), supported by fillers/barge-in handling and the LivekitCallManager ## The two brains **The fast brain** is `Assistant(Agent)` in [`unify/conversation_manager/medium_scripts/call.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/medium_scripts/call.py) — a LiveKit Agents worker running in its own subprocess. It owns the real-time loop: Deepgram STT (with diarization), Silero VAD and turn detection, a lightweight LLM for conversational responses, and TTS through Cartesia or ElevenLabs depending on the assistant's `voice_provider`. Its job is to keep the conversation feeling human *right now*. **The slow brain** is the ordinary [`ConversationManager`](/communication/developers/conversation-manager) turn loop — same code as for text, now fed per-utterance events. It owns judgment: what to actually say, when to start work, when to end the call. They communicate over a Unix domain socket ([`domains/ipc_socket.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/ipc_socket.py)): * **Upward:** each completed user turn publishes an inbound utterance event (`InboundPhoneUtterance`, `InboundUnifyMeetUtterance`, …), which schedules a slow-brain turn like any other message. * **Downward:** the slow brain speaks through its `guide_voice_agent` tool, which publishes a `FastBrainNotification` — with `should_speak=True` the fast brain delivers the message verbatim over TTS, and an optional `fast_brain_guidance` field steers how it handles the *next* few turns on its own. ## Call lifecycle `LivekitCallManager` ([`domains/call_manager.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/call_manager.py)) owns session mechanics: it names rooms (`unity_{assistant_id}_{medium}`), prewarms a persistent worker subprocess, dispatches the agent into a room per call, and bridges broker events into the subprocess. On the gateway side, inbound phone calls arrive via `twilio_call_webhook` ([`adapters/twilio.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/twilio.py)), which sets up the Twilio↔LiveKit SIP bridge; outbound calls go through the [`phone` channel](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/phone/views.py) (`send_call`, `dispatch_livekit_agent` — SIP trunks are created per provisioned number at purchase time). Unify Meet sessions skip telephony entirely: the Console joins the LiveKit room directly, rung via `ring_unify_meet()` on the `ConversationManager` (with its \~25-second no-answer fallback to text). Google Meet and Teams meetings are joined via browser automation rather than SIP. ## The feel of a call, mechanically * **Fillers.** When a user turn needs the slow brain and it hasn't answered yet, the fast brain schedules a short buffer phrase ("one moment…") — `_schedule_buffer_filler` in `call.py` — suppressed if the slow brain already responded. * **Turn classification.** Between full slow-brain turns, the fast brain classifies each user turn (`select_fast_brain_turn` in [`domains/fast_brain_turn.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/fast_brain_turn.py)): smalltalk it can answer itself, silence, deferral to the slow brain, or — when the hang-up gate is armed — a natural close. * **Barge-in.** If the user talks over TTS, a `VoiceInterrupt` event records the `spoken_prefix` actually delivered and the `unheard_remainder`, so the slow brain knows exactly what the user did and didn't hear and can re-weave the rest. * **No turn preemption.** Speaking mid-turn does not cancel the running slow-brain turn. The new input queues as the single pending turn (see [the conversation runtime](/communication/developers/conversation-manager)), and the fast brain covers the gap. Urgency-based preemption was tried and removed. * **Proactive speech.** During long silences while work runs, `ProactiveSpeech` ([`domains/proactive_speech.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/proactive_speech.py)) decides whether the assistant should say something unprompted. ## Who said what Attribution comes from the meeting roster and the platform's own participant signals — never from matching voices. In a browser meeting the two halves arrive separately: the transcriber tags each finalised utterance with an anonymous diarization id (`S0`, `S1`), while the meeting platform reports spans of who was speaking over the Recall relay. [`meet_speaker_map.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/meet_speaker_map.py) pairs them by **overlap of time spans** rather than by sampling who is speaking when a final lands — by then the speaker has stopped and the platform has already sent `speech_off`, so instantaneous sampling fires only when someone talks over the previous speaker's tail, exactly when it's most likely to name the wrong person. Votes accumulate rather than binding on first sight, because diarization ids are per-call and drift. Elsewhere, an unresolved turn is attributed to the call contact and the slow brain infers the real speaker from the conversation. Enrolled voice embeddings are **not** compared against live audio to decide who is speaking. That path was removed after production audio showed the cosines conflated distinct speakers — including the assistant's own TTS voice — about as often as they separated them, so a matched label was as likely to be wrong as right. `SpeakerTracker` ([`speaker_id.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/speaker_id.py)) remains as capture-only machinery: clustering keeps auto-enrollment single-voice pure and counts distinct voices behind `VoiceEnrollmentCaptured` / `VoiceEnrollmentSuggested`. ## Hang-up semantics Ending a call is a two-brain negotiation with three tools: | Tool | Effect | | ----------------------- | ------------------------------------------------------------------------------------------------------ | | `allow_hang_up(reason)` | Arms the hang-up gate on `LivekitCallManager` — the fast brain may now end the call at a natural close | | `withdraw_hang_up()` | Disarms the gate | | `hang_up()` | Immediate teardown — deferred just long enough for any pending spoken line to be delivered | This split is why short scripted calls (like onboarding channel tests) end gracefully on their own, while open-ended calls never end abruptly unless the slow brain explicitly decides to. ## Screen share during Meet Screen-share state flows as events (`AssistantScreenShareStarted/Stopped`, `UserScreenShareStarted/Stopped`, webcam variants) from the Console through the internal adapter. Frames from shared user tracks are captured (`UserTrackCaptureManager` in `call.py`), buffered on the `ConversationManager`, and attached to the next slow-brain turn as vision input — which is how "look at my screen and tell me what's wrong" actually works. Screenshots also land on disk under `Screenshots/` for the Actor to reference during `act` work. # Discord Source: https://docs.unify.ai/communication/discord DM your assistant or bring it into your server Your assistant can live on Discord too — as a bot you can DM directly or @mention in your server's channels. ## Setup Under **Account → Contact Info**, add your Discord user ID (the numeric "snowflake"). To find it: enable **Developer Mode** in Discord's settings, then right-click your profile and choose **Copy User ID**. In your assistant's **Contact Details**, add Discord. Your assistant is assigned a bot identity that can message you. Discord contacts have a one-time setup cost and a monthly fee, paid in credits — shown in the Console before you confirm. ## What you can do * **Direct messages** — DM the assistant's bot for a private conversation. * **Server channels** — with the bot in your server, @mention it in a channel and it responds there. * **Images** — your assistant can include images in its Discord messages. # Email Source: https://docs.unify.ai/communication/email Give your assistant a real mailbox Your assistant can send and receive email from a real mailbox — replying in threads, handling attachments, and looping in cc'd colleagues, just like any teammate. **If this is your T-W1N** (your digital twin): its address is platform-managed and already set up — there's nothing to create or configure. T-W1N's address is shared across the platform, and mail you send to it reaches *your* T-W1N because your own contact details are [verified](/communication/setup) — which is why verifying them matters. That's separate from T-W1N working inside **your** mailbox through your [workspace connection](/workspace/email): mail it sends from there goes out as you, from your account. The setup below is for **hired teammates**. ## How your assistant gets an email address Your assistant uses a **Google (Gmail) or Microsoft (Outlook / Microsoft 365) account** that you connect for it. There's no synthetic address — it's a real mailbox, which means email from your assistant lands like email from a person. For a hired assistant, create a **brand-new** Google or Microsoft account that belongs to the assistant (for example, `alex.assistant@yourcompany.com`). Don't connect your own personal account to a hired assistant. In your assistant's **Contact Details**, choose **Configure** next to Email, pick Google Workspace or Microsoft 365, and sign in with the assistant's account. Email is the essential piece; you can also grant Calendar, Drive, Contacts, Tasks, Teams, or SharePoint access at the same time to unlock more of your assistant's capabilities. Connecting email is free — there are no setup or monthly credit costs for email contacts. This connection is your assistant's [workspace connection](/workspace/overview) — the same sign-in that enables email also unlocks calendar, files, contacts, and tasks, depending on what you grant. ## What your assistant can do with email * **Receive and read** — email its address and it reads the message, the thread history, and any attachments. * **Reply in-thread** — replies stay in the same conversation, with proper threading, so email clients group everything correctly. * **Reply-all** — when a thread has several people on it, your assistant can keep everyone in the loop. * **Send fresh emails** — with to, cc, and bcc recipients, and attachments. * **Email people for you** — ask your assistant to "email the venue and ask about availability" and it drafts, sends, and watches for the reply. ## Email in the flow of work Email is your assistant's channel of choice for anything long-form: reports, summaries, documents. It's also where your [T-W1N checks in](/communication/behavior) if you've been away from the platform for a while — those come from your T-W1N rather than a hired teammate, and stop as soon as you ask. You don't have to manage the mailbox. Your assistant watches its inbox and responds to new mail on its own — the connected account is simply where its email lives. # Group chats Source: https://docs.unify.ai/communication/group-chats Team rooms, ad-hoc groups, and DMs with colleagues [Console chat](/communication/console-chat) is a thread between you and one assistant. It isn't the only thread you get. In an organization, the assistant list is a full roster — your **teams**, your human **colleagues**, and your **assistants** — and any of them opens a conversation. Rooms work like chat with one difference that changes how they feel: several AI teammates can be listening at once, so *who answers* is a real question. Colleague DMs and groups exist inside an **organization**. On a personal account, the roster is just your own assistants. ## Three kinds of room * **Colleague DMs** — pick a person from **Real** in the roster for a one-to-one thread with them, presence dot and all. No assistant is listening to a human DM; it's just the two of you. * **Groups** — an ad-hoc room you create with any mix of colleagues and assistants, optionally named. You can add and remove members later. * **Team chats** — one shared thread per [team](/teams/overview), with every member in it, human and AI. ## Who answers Every message in a team or group room is delivered to **every** AI teammate in that room — not just the one you had in mind. They're told to work out whose turn it is rather than all pile in: * **Name someone** and it's theirs. Type `@` for a picker, or just type the name — a hand-typed `@Ada` addresses Ada exactly as much as a picked one. * **Name a teammate other than the one you'd expect** and the others stay quiet, even when they could have answered. Talking over a named teammate is worse than saying nothing. * **Name nobody** and the best-placed teammate takes it — the one whose work it's plainly about. This is the case where you might get silence, so name someone if it matters. Your personal T-W1N stays out of team and group rooms. Its job is your own thread, not the room. Rooms have a brake on assistants talking to each other: after **three** consecutive assistant messages with no human turn, the room stops waking assistants until a person says something. If a room goes quiet mid-exchange between two assistants, that's why — say anything and it resumes. ## Calls Any room can become a call, not just your 1:1 with an assistant. The call button rings everyone in the room; people answer, decline, or join late, and assistants are dispatched into the same call. It's the same machinery as [Unify Meet](/communication/unify-meet) — one call surface across colleague DMs, groups, teams, and assistant threads. ## The composer Rooms use the same composer as [Console chat](/communication/console-chat): attachments, voice notes transcribed into the box, camera capture, emoji reactions, and a search over the room's history. A group is the quickest way to get two assistants working the same problem in front of you — put both in a room with the colleague who owns the work, and name whichever one you want to pick up each turn. # Microsoft Teams Source: https://docs.unify.ai/communication/microsoft-teams Bring your assistants into Teams with the org-installed Unify app If your organization runs on Microsoft Teams, the **Unify Teams app** brings your assistants right into it — one-on-one chats, group chats, and channel threads. It's an org-wide app your admin installs once, so everyone can reach your assistants without any per-assistant setup. This is the Teams equivalent of the [Slack app](/communication/slack): the assistant shows up as the shared Unify bot, not as a person. There's a second, separate way to use Teams: an assistant can act through its **own connected Microsoft 365 account** (the same account as its [mailbox](/workspace/email)) — appearing as that user rather than the shared app. That path is part of the workspace connection and is documented under [Workspace → Teams](/workspace/teams). ## Setup A Microsoft admin installs the **Unify Teams app** into your organization's Teams. Once it's installed, every assistant in the org — and your T-W1N — is reachable through it; there's nothing to configure per assistant. ## Messaging * **1:1 chat** — message the Unify app in Teams and your T-W1N, the coordinator that fields anything unaddressed, picks up the conversation. * **Group chats** — add the app to a group chat and @mention it to bring it in; it replies in the same conversation. * **Channels** — @mention the app in a channel and it replies in the thread, keeping channels tidy. ## Reaching a specific teammate Because the app is shared, addressing works like Slack: * In a **channel or group chat**, @mention the app and include the teammate's name to reach a specific assistant (for example, `@Unify Alex, can you…`). @mention it with no name and the message goes to your **T-W1N**, who can hand off from there. * In a **1:1 chat**, messages go to your T-W1N — there's no name to add, so naming a teammate isn't used here. Teams only delivers channel and group-chat messages to the app when it's **@mentioned**, so remember the mention when you want the assistant to see a message in a shared space. In a 1:1 chat every message is delivered. ## Good to know * Your assistant only speaks in a channel when it's mentioned or directly relevant — it won't spam your team's conversations. * It's the same assistant across every surface: a task you discussed in Teams can be delivered over email, and vice versa. * **Meetings** are separate from the app. Your assistant can join and host Teams meetings — see [Meetings](/workspace/meetings) for joining, and [Workspace → Teams](/workspace/teams) for creating and scheduling them. # Overview Source: https://docs.unify.ai/communication/overview Every way to talk to your assistant Your assistant isn't locked inside an app. You can reach it — and it can reach you — over all the channels you already use every day. Message it on WhatsApp from the train, forward it an email, call its phone number, or bring it into a video call and share your screen. However you get in touch, it's the same assistant with the same memory of everything you've discussed. ## Channels at a glance | Channel | You → Assistant | Assistant → You | Setup needed | | --------------------------------------------------------------------- | ------------------------ | ----------------------------- | ------------------------------------------------------------------- | | [Console chat](/communication/console-chat) | Yes | Yes | None — works immediately | | [Group chats](/communication/group-chats) (teams, groups, colleagues) | Yes | Yes | None — organization accounts | | [Unify Meet](/communication/unify-meet) (browser calls) | Yes | Yes — it can ring you | None — works immediately | | [Phone calls](/communication/phone-calls) | Yes | Yes | Assistant phone number | | [SMS](/communication/sms) | Yes | Yes | Assistant phone number | | [WhatsApp](/communication/whatsapp) | Yes | Yes | WhatsApp contact | | [Email](/communication/email) | Yes | Yes | Connected Gmail or Outlook | | [Slack](/communication/slack) | Yes | Yes | Slack app install | | [Microsoft Teams](/communication/microsoft-teams) | Yes | Yes | Teams app install ([or Microsoft 365 connection](/workspace/teams)) | | [Discord](/communication/discord) | Yes | Yes | Discord ID on your profile | | [API](/communication/api) | Yes — from your own code | Yes — it replies to your call | API key | | [Google Meet & Teams meetings](/workspace/meetings) | Invite it to a meeting | It can create Teams meetings | Meeting link | ## Two channels work out of the box The moment your assistant exists, you can [chat with it in the Console](/communication/console-chat) and [talk to it on Unify Meet](/communication/unify-meet) — voice and video calls right in your browser, with screen sharing in both directions. Everything else takes a couple of minutes of one-time setup. ## Setting up the other channels Setup follows the same pattern for every channel your assistant talks to people on, and is covered in detail in [Setting up channels](/communication/setup). ([API messages](/communication/api) are the exception — no contact details, just your API key.) Add and verify your phone number or WhatsApp number under **Account → Contact Info**. This is how the platform knows an incoming message or call is really from you. Open your assistant's **Contact Details** and add a phone number, WhatsApp contact, or connected email account. Send a first message or make a first call. Your assistant will pick up the conversation from wherever you left off on any other channel. Not sure where to start? Just ask your assistant. Setting up its own email, phone number, or WhatsApp is something it can walk you through step by step — the easiest way is to hop on a call and share your screen. ## One assistant, one conversation Channels are doorways, not silos. If you discuss a project over email in the morning, you can text your assistant "any update on that?" over SMS in the afternoon — it knows exactly what you mean. Your assistant keeps a single, continuous relationship with you across every channel. A few things that hold everywhere: * **It matches your language.** Write to your assistant in any language and it replies in kind. * **It knows who's who.** Each person your assistant talks to is a [contact](/communication/contacts), with their own phone number, email, WhatsApp, and preferences. * **It respects boundaries.** People can be marked as do-not-contact, and your assistant will never message them. * **Your T-W1N checks in.** If you've been away from the platform for a while, your T-W1N sends a short check-in email — a few at most, and only ever from your T-W1N, never a hired teammate. Ask it to stop and it stops. ## What's in this section Text chat with file attachments, reactions, and voice input. Team rooms, ad-hoc groups, and DMs with colleagues. Browser voice & video calls with two-way screen sharing. Real phone calls with a natural, human-sounding voice. Text your assistant like you'd text a colleague. Messages, media, reactions, and WhatsApp voice calls. Full email conversations from a real mailbox. } href="/communication/slack"> DMs and channel mentions in your workspace. } href="/communication/microsoft-teams"> Chats, group chats, and channels via the org-installed Unify app. DMs and server channels. Message it from your own code and poll for the reply. } href="/workspace/meetings"> Your assistant joins Google Meet and Teams meetings. Contact details, verification, and channel costs. Pick, preview, or design the voice it uses on calls. When it speaks up, when it stays quiet, and how to steer it. Everyone your assistant knows, with per-person response policies. Every conversation on every channel, in one consolidated record. # Phone calls Source: https://docs.unify.ai/communication/phone-calls Call your assistant — or have it call you — on a real phone number Your assistant can have its own dedicated phone number. Once it does, phone calls work exactly the way you'd expect: dial the number and your assistant answers in its own voice; ask it to call you and your phone rings. ## Getting your assistant a number Numbers are provisioned from your assistant's **Contact Details** in the Console — see [Setting up channels](/communication/setup) for the full walkthrough. In short: 1. Verify **your own** phone number under **Account → Contact Info** (you'll receive a 6-digit code by SMS). 2. In your assistant's Contact Details, choose **Add phone**, pick the country the number should be based in, and create it. Numbers are currently available in: **United States, United Kingdom, Australia, Canada, Finland, Netherlands, Puerto Rico, Thailand, and Poland**. Phone numbers have a one-time setup cost and a small monthly fee, paid in credits. The exact amounts are shown in the Console before you confirm. ## Calling your assistant Dial its number from your phone. Your assistant picks up and talks with you naturally — same voice, same memory, same colleague you know from chat. Calls are for people your assistant knows. On the shared numbers T-W1N uses, a caller it doesn't recognize never gets through — they hear a short message directing them to the Console. On an assistant's own number the call does reach the assistant, but an unknown caller isn't treated as a known contact: they're recorded as a [contact](/communication/contacts) with responses switched off, and your assistant comes to you for guidance rather than acting on what a stranger asked for. ## Your assistant calling you (or others) Ask your assistant to call you — "give me a call in 10 minutes" — and your phone rings. If you don't pick up, it knows the call went unanswered and follows up over text instead of leaving you hanging. Your assistant can also make calls on your behalf: booking appointments, chasing suppliers, following up with clients. Just tell it who to call and what to accomplish, and it reports back when it's done. ## What calls are like * **Natural voice.** Your assistant speaks with the [voice you chose](/communication/voice) — you can interrupt it mid-sentence and it adapts, just like a person. * **Concise by design.** On the phone your assistant keeps its answers short and conversational rather than reading you essays. * **It can bring someone in.** Your assistant can add a third person to a call when a human handoff is needed. * **It knows when to hang up.** For quick calls it wraps up with a natural goodbye rather than lingering on the line. * **Calls are recorded.** Recordings and transcripts are linked to the conversation — see [Recordings & transcripts](/communication/recordings-and-transcripts). During a call, your assistant generally won't text you at the same time — with a few sensible exceptions, like sending a link or a document that's easier to read than to hear. # Transcripts & recordings Source: https://docs.unify.ai/communication/recordings-and-transcripts Every conversation, every channel, one consolidated record Your assistant keeps a faithful record of its communication — that's part of what makes it useful. Nothing you discussed is ever "lost to the phone call," and nothing it said on your behalf is unreviewable. ## The Transcripts pane The **Transcripts** pane (in the Brain section of your assistant's page) is the consolidated archive: *"every conversation across chat, email, call, SMS, and WhatsApp in one consolidated thread"* — plus Slack and Discord. Where [Chat](/communication/console-chat) is the live thread between you and your assistant, Transcripts is the historian's view of **everything, with everyone**. ### Browsing * **Pick a channel.** The channel rail filters the record to one medium — **Chat**, **Email**, **Call**, **SMS**, **WhatsApp**, **Slack**, **Discord** — or **All** for the full stream. Calls of every kind (Unify Meet, phone, WhatsApp calls, Google Meet, Teams meetings) are gathered under **Call**. Channels without a rail entry of their own — [Microsoft Teams](/communication/microsoft-teams) messages and [API messages](/communication/api) — are in the record too; find them under **All**. * **Switch layout.** **Threads** mode groups the record into conversations — an email thread, a call, an SMS exchange — each showing its subject, channel, message count, and participants. **Feed** mode is a reverse-chronological stream of individual messages, best for scanning what happened recently; click any entry to open its thread. * **Search content.** One search box spans every channel at once — subjects, participant names, and message bodies. A phrase you half remember from "some email or maybe a text" is findable either way. ### Reading a thread Open a thread and you get the full exchange: every message with its sender, recipients, and timestamp — email bodies rendered in full under their subject, call conversations as spoken turn by spoken turn, message threads as they unfolded. A copy button on each message grabs its text. This is where a hired teammate's [work as a liaison](/hiring/liaison) becomes inspectable: the thread it ran between the contractor and the tenant reads like any email thread — except your teammate was the one holding it. ## Call recordings Voice calls — [Unify Meet](/communication/unify-meet), [phone](/communication/phone-calls), and [WhatsApp calls](/communication/whatsapp) — are **recorded automatically**, in addition to being transcribed. When a recording is ready, a link is attached to the conversation, so you can listen back to exactly what was said rather than trusting the transcript alone. ## One record, three windows The same underlying record powers three views, each tuned to a job: | Window | Best for | | ----------------------------------- | ------------------------------------------------------------------------------ | | [Chat](/communication/console-chat) | The live conversation between you and your assistant, right now | | Chat's **Search conversation** | Finding something in *your* thread — with date, sender, and attachment filters | | **Transcripts** | The full cross-channel archive — everyone your assistant talks to, everywhere | Together with [Contacts](/communication/contacts) (who) and the [Actions pane](/learning/watching-it-work) (what it did), Transcripts (what was said) completes the audit trail: nothing your assistant does on your behalf is a black box. # Setting up channels Source: https://docs.unify.ai/communication/setup Contact details, verification, and what channels cost Every channel beyond Console chat and Unify Meet is switched on from two places in the Console: **your** contact info, and your **assistant's** contact details. ## Step 1 — Verify your own contact info Go to **Account → Contact Info**. This is where you tell the platform how to recognize you on external channels: | Field | How it's verified | | --------------- | ----------------------------------------------------------------------------------------------- | | Email | Your login email — already verified | | Phone number | 6-digit code sent by SMS | | WhatsApp number | 6-digit code sent on WhatsApp | | Discord user ID | Entered directly (enable Developer Mode in Discord, right-click your profile, **Copy User ID**) | Verification matters: it's how a text from your number, or a WhatsApp message from your account, gets routed to your assistant as *you* — with all your shared context — rather than treated as a stranger. ## Step 2 — Give your assistant its channels Hover over your assistant in the assistant list, open the **⋮** menu, and choose **Contact Details** (or open the Contact Details card on its profile). From there: Choose the country the number should be based in — available in the US, UK, Australia, Canada, Finland, Netherlands, Puerto Rico, Thailand, and Poland — then create it. The number handles both [calls](/communication/phone-calls) and [SMS](/communication/sms). You'll need a verified phone number on your own profile first. Create the WhatsApp contact (your own WhatsApp number must be verified first), then **send the first message** — that opens the conversation and lets your assistant call you on WhatsApp later. See [WhatsApp](/communication/whatsapp). Choose **Configure** and connect a Google or Microsoft account for your assistant. For hired assistants, create a brand-new account rather than connecting your own. See [Email](/communication/email). Add Discord and your assistant gets a bot identity that can DM you and join your servers. See [Discord](/communication/discord). Choose **Add to Slack** and authorize the Unify app. You connect a workspace **once** for the whole account or organization — every assistant becomes reachable through it, and you address a specific one by naming it in the mention. Only the account owner, or an org owner or admin, can connect it. See [Slack](/communication/slack). Nothing to create per assistant — a Microsoft admin installs the Unify Teams app once for the organization and every assistant is reachable through it. See [Microsoft Teams](/communication/microsoft-teams). (An assistant can also act through its *own* Microsoft 365 account instead; that's part of the [workspace connection](/workspace/teams).) **Using T-W1N (your digital twin)?** Its contact details are platform-managed — channels are provisioned automatically, and messages from your verified identity are routed to it. There's nothing to create or configure beyond verifying your own contact info. ## What channels cost Some channels carry real-world costs (phone numbers are leased, WhatsApp senders are registered), which are paid in credits from your wallet: | Channel | One-time setup | Monthly fee | | -------------------------- | -------------- | ----------- | | Phone number (calls + SMS) | Yes | Yes | | WhatsApp | Yes | Yes | | Discord | Yes | Yes | | Email | Free | Free | | Slack & Microsoft Teams | Free | Free | | Console chat & Unify Meet | Free | Free | Exact amounts vary by channel and country, and are always shown in the Console before you confirm. Monthly fees are deducted from your credit wallet at the start of each month; if your balance runs short, there's a grace period before a channel is deactivated, and you'll be notified by email first. Deleting a contact method is irreversible — the phone number or WhatsApp sender is released — but you can create a new one at any time. ## Let your assistant drive The Console walks you through channel setup with an onboarding checklist — first message, first call, email, phone, WhatsApp — and your assistant guides you through each step, testing every channel with you as you go. And remember: your assistant knows how all of this works. If in doubt, hop on a [Unify Meet](/communication/unify-meet) call, share your screen, and it will walk you through the whole thing in a couple of minutes. # Slack Source: https://docs.unify.ai/communication/slack Reach your assistants from your Slack workspace The **Unify Slack app** brings your assistants into Slack — DMs, channels, and threads. It's installed once for your account or organization, and every assistant becomes reachable through it. Like the [Teams app](/communication/microsoft-teams), your assistants speak as the shared Unify app rather than as separate Slack members, so addressing works by naming the teammate you want. ## Setup Open your assistant's **Contact Details → Slack** and choose **Add to Slack**. You connect a workspace once — every assistant on the account (or in the organization) becomes reachable through it, with nothing to configure per assistant. Only the account owner, or an organization owner or admin, can connect the workspace. If you're not one of them, the Slack tab tells you who to ask. ## Reaching a specific teammate Mention the app and include the teammate's name: ``` @Unify Alex, can you pull yesterday's numbers? ``` From there, the conversation sticks: * **In a channel**, naming a teammate hands them the thread. Replies in that thread reach the same assistant without naming them again. * **In a DM** with the app, the first message goes to your **T-W1N**, the coordinator that fields anything unaddressed. Name a teammate in the DM and the conversation is pinned to them from then on. * **If the name doesn't match** anyone — or matches more than one teammate — your T-W1N takes the thread and sorts it out in place rather than guessing. In a channel, a message that names nobody and isn't part of an established thread reaches **no one**. Name the teammate you want on the first message; after that the thread carries the routing. A thread or DM that goes quiet for two weeks forgets which assistant it belonged to. Name the teammate again and it picks straight back up. ## What you can do * **Direct messages** — the same one-on-one conversation you'd have anywhere else, with your T-W1N or with any teammate you've named. * **Channel threads** — mention the app in a channel and it replies in thread, keeping the channel tidy. Invite the app to the channel first. * **File attachments** — share files in Slack and your assistant picks them up and works with them. ## Good to know * Your assistant only speaks when it's addressed — it won't spam your team's conversations. * Coordinators are personal. In an organization, each member's unaddressed messages go to *their own* T-W1N, not to a shared one. * It's the same assistant across every surface: a task you discussed in Slack can be delivered over email, and vice versa. # SMS Source: https://docs.unify.ai/communication/sms Text your assistant like you'd text a colleague Once your assistant has a [phone number](/communication/phone-calls), SMS just works — text the number and your assistant texts back. ## Setup SMS shares the assistant's phone number, so there's nothing extra to configure. If you've added a phone number to your assistant, you can already text it. If not, see [Setting up channels](/communication/setup). ## What it's like * **Texts feel like texts.** Your assistant writes short, natural messages — and if it has a lot to say, it splits things into a few quick texts rather than sending you a wall of words. * **It's the same conversation.** Text "how's that report coming?" and your assistant knows exactly which report, even if you asked for it over email last week. * **It can text first.** Ask your assistant to remind you about something by text, or to message you when a task is done. ## Good to know * SMS is **text only** — for photos, documents, and voice notes, use [WhatsApp](/communication/whatsapp) or [email](/communication/email) instead. * Like calls, SMS is for people your assistant **knows**. A text to one of T-W1N's shared numbers from an unrecognized number gets an automated reply rather than a conversation. On an assistant's own number the text reaches it, but the sender lands as a [contact](/communication/contacts) with responses switched off — your assistant tells you about them and asks how to handle it instead of replying. # Unify Meet Source: https://docs.unify.ai/communication/unify-meet Voice and video calls in your browser, with screen sharing both ways Unify Meet is a live voice and video call with your assistant, right in the Console — no phone number, no downloads. It's the closest thing to sitting next to a colleague: you talk naturally, share your screen to show it something, or watch **its** screen while it works. ## Starting a call Click the **Call** button on your assistant's page. The call connects in your browser and your assistant greets you in its own voice. If you join muted, your assistant will politely point out that it can't hear you — click the microphone at the bottom of the Meet window to unmute. ## Your assistant can call *you* too When your assistant has something worth a live conversation — a question about a task, something to show you — it can ring you on Unify Meet. A card appears in the Console with **Answer call** and **Decline** buttons. If you don't answer within about 25 seconds, no harm done: your assistant simply carries on over chat instead. ## In-call controls | Control | What it does | | ------------------------------------- | ------------------------------------------------------- | | Microphone | Mute / unmute yourself | | Camera | Turn your camera on or off — your assistant can see you | | **Share your screen** | Show your assistant what's on your screen | | **Show assistant screen** | Watch your assistant's own computer while it works | | **Enable mouse and keyboard control** | Take the wheel on your assistant's computer | | Chat | Send text and files without leaving the call | | Hang up | End the call | ## Screen sharing works both ways This is where Unify Meet really shines: * **You share with your assistant.** Walking it through a task, showing it a confusing form, or asking "what am I looking at here?" — share your screen and it sees what you see, and guides you step by step. * **Your assistant shares with you.** Your assistant has [its own computer](/their-computer/overview). Ask to see its screen and you can watch it work in real time — and if you enable mouse and keyboard control, you can [operate its machine yourself](/their-computer/watching-and-control). Whenever you're stuck on setup — connecting email, adding a phone number, linking an app — the fastest fix is a Meet call with your screen shared. Your assistant will walk you through it in a couple of minutes. ## Good to know * **Natural conversation.** You can interrupt your assistant mid-sentence, just like a real call — it stops, listens, and picks up gracefully. * **Thinking time.** For questions that need real work, your assistant may say something brief like "one moment" while it digs in, then comes back with the answer. * **Other people in the room.** If someone else speaks near your microphone, your assistant can tell it's a different speaker and won't butt into their conversation uninvited. * **One call at a time.** Your assistant holds one live voice conversation at a time — if you're on a Meet call, it won't simultaneously be on the phone. * **Not only one-to-one.** The same call surface runs across [rooms](/communication/group-chats): ring a colleague, a group, or a whole team, and the room's assistants join the call with everyone else. # Your assistant's voice Source: https://docs.unify.ai/communication/voice Choose how your assistant sounds on calls Whenever your assistant speaks — on [Unify Meet](/communication/unify-meet), [phone calls](/communication/phone-calls), [WhatsApp calls](/communication/whatsapp), or in [meetings](/workspace/meetings) — it uses a voice you choose. ## Choosing a voice You pick a voice when you first hire an assistant, and you can change it any time from the assistant's **Edit profile** page: 1. Open the **Voice** section. 2. Browse the voice list and click a voice to select it. 3. Use **Preview selected voice** to hear it speak before you commit. The voice applies everywhere your assistant talks — one voice, every channel. ## Beyond the presets Alongside the ready-made voices, you can create voices of your own: * **Clone a voice** — create a custom voice from a recording. * **Design a voice** — describe the voice you want and preview generated options until one fits. Your custom voices appear in the voice list next to the presets, for any of your assistants to use. Your assistant's voice is part of its identity — pick one that matches the persona you've given it. You can also give it a profile photo and an animated video avatar from the same profile page. # WhatsApp Source: https://docs.unify.ai/communication/whatsapp Message and call your assistant on WhatsApp WhatsApp is one of the richest ways to talk to your assistant: text, photos, documents, emoji reactions, and even voice calls, all from the app you already have on your phone. ## Setup Under **Account → Contact Info**, add your WhatsApp number. You'll receive a 6-digit verification code to confirm it's yours. In your assistant's **Contact Details**, create a WhatsApp contact. Your assistant appears as a WhatsApp business contact you can message. Message your assistant first. This opens the conversation — and it's also what allows your assistant to call you on WhatsApp later. WhatsApp contacts have a one-time setup cost and a monthly fee, paid in credits — the amounts are shown before you confirm. ## Messaging * **Text** — full two-way conversation, same memory as every other channel. * **Media** — send photos, screenshots, and documents; your assistant reads them and can send files back. * **Reactions** — react to your assistant's messages with emoji; it notices. ### The 24-hour window WhatsApp has a platform-wide rule for business messaging: once you message your assistant, it can reply freely for 24 hours. If more than a day passes without a message from you, your assistant can only send a short, pre-approved greeting — something like "I have a message for you" — until you reply, which reopens the window. So if you get a brief nudge from your assistant instead of the full message, just reply — the real message follows immediately. ## WhatsApp voice calls Your assistant can talk to you on WhatsApp calls, not just in text: * **You call it** — call your assistant in WhatsApp like any contact, and it answers in its voice. * **It calls you** — WhatsApp requires your permission before a business can call you. The first time, your assistant sends a call-permission request in the chat; accept it, and from then on it can ring you on WhatsApp. Calls work just like [phone calls](/communication/phone-calls): natural voice, interruptions welcome, recordings and transcripts kept. WhatsApp is ideal when you're away from your desk — snap a photo of a document, a whiteboard, or a receipt and send it straight to your assistant with a one-line instruction. # Liaising across the team Source: https://docs.unify.ai/hiring/liaison Hired teammates talk to everyone — people and assistants alike The deepest difference between a twin and a hired teammate is **audience**. Your T-W1N speaks only to you — when it needs to reach anyone else, it does so explicitly on your behalf, with your say-so. A hired teammate is built for the opposite: it's the connective tissue between stakeholders, able to talk to anyone involved in its work. ## Working with many people A hired teammate holds real multi-party conversations, concurrently: * **Email threads** with cc, bcc, and reply-all — keeping every stakeholder in the loop on the threads it runs from its own mailbox. * **Group chats and channels** — group conversations on Teams, channel threads on Slack, each under its own named profile. * **Its own phone line** — clients, contractors, and colleagues call the teammate's number and reach *it*, directly. * **Several conversations at once** — it can be mid-thread with a supplier while answering a colleague, without dropping either. The people it talks to don't need Unify accounts. A repairs teammate can coordinate between the tenant (SMS), the contractor (phone), and the housing officer (email) — three humans, three channels, one teammate holding the thread. ## Response policies: who gets what Talking to everyone doesn't mean treating everyone the same. Every person a teammate deals with is a **contact** with its own response policy — the standing instructions for how to engage them: * **Its boss** (whoever hired it) gets the trusted-manager policy: *"do whatever they ask you to do within reason, and do not withhold any information from them."* * **Everyone else** starts with a polite-but-guarded default: engage helpfully and respectfully, *"but you do not need to take orders from them"* — and never share sensitive information about any other person or company. * **Strangers get silence first.** When an unknown number or address messages in, a contact is created with responses **off** — the teammate investigates or checks with you before ever replying. And a contact marked do-not-respond is a hard line: the teammate physically cannot message them, whoever asks. You tune all of this in plain language — "treat Dana as a trusted stakeholder", "never discuss pricing with contractors", "stop responding to that recruiter" — and each rule becomes part of the relevant contact's policy. ## Working with other assistants Teammates coordinate with each other too — deliberately, through structured handoffs rather than free-form bot chatter: * **Your T-W1N delegates.** It can hire a colleague, brief it, and hand over follow-up work — "assign the weekly reporting to the ops teammate" — with the work landing on the colleague's own runtime to execute with its own tools. Delegation is honest about its asynchrony: your twin reports that work was *assigned*, and confirms completion only when it actually happens. * **Colleagues defer upward.** When you ask a hired teammate for something org-shaped — new members, team changes, shared credentials — it points the work to your T-W1N, which holds those keys. * **Teams carry the shared context.** The day-to-day medium of collaboration is the [team's shared pool](/teams/shared-context): playbooks, knowledge, data, and tasks that every teammate on the team — twins and specialists alike — reads and contributes to. ## The division of labor, in one picture | | Your T-W1N | Hired teammate | | --------------------- | ---------------------------------------------- | --------------------------------------------- | | Speaks directly to | You, only | Anyone — boss, colleagues, externals | | Reaches third parties | Via explicit, delegated actions on your behalf | Directly, under per-contact response policies | | Multi-party threads | No | cc/reply-all, group chats, channels | | Serves | One person | A team, a function, a process | | Org setup powers | Yes — invites, teams, hiring | Defers to T-W1N | A good structural rule: **route personal work through your twin, and give every multi-stakeholder process a hired owner.** The moment three people need to email "the assistant", that assistant should be a hired teammate with its own name, mailbox, and number. # Onboarding a teammate Source: https://docs.unify.ai/hiring/onboarding The hire flow, and what to set up in the first day Hiring happens in the Console: the **Onboard** button above the assistant list opens the **Onboard Teammate** dialog. (In an organization workspace, hiring is for Owners and Admins — and if you don't have any teammates yet, the dialog opens for you automatically.) The form arrives pre-filled with a randomized profile — keep it, reroll it with **Randomize**, or shape every field yourself. ## The form, top to bottom First and last name, an optional **Role** label ("e.g. Growth marketing") that appears alongside the teammate in the assistant list, and a timezone — which anchors its working clock for [schedules](/tasks/creating#schedules). Name, timezone, About, and voice are required; everything else is optional. Style the avatar (body, color, outfit, antenna) and pick the [voice](/communication/voice) it will use on every call. Click the avatar any time to preview the selected voice. Describe the persona — background, personality, focus. Note the help text: *"The bio doesn't influence the teammate's abilities. All teammates come with the same foundational skills and can specialize in whichever area you want them to."* The About shapes character and emphasis, not capability. The most consequential choice. Create a **new** Google or Microsoft account for the teammate (or ask IT to), sign into it on your machine, and pick the matching provider — see [why the dedicated account matters](/hiring/twin-vs-teammates). This step is deliberate rather than skippable-by-accident: if you submit without either choosing a provider or ticking **Skip**, the form stops and asks you to decide, advising that *"It's advised to create a workspace for your new digital twin now, so they can get started right away."* Skipping is fine — you can connect the workspace any time later from the teammate's profile. Choose the operating system for the teammate's [own machine](/their-computer/the-machine) — **Ubuntu** or **Windows**. This is the one setting that's **fixed after onboarding**, so pick for the software world it will live in. Click **Onboard Teammate** and you're done — the new teammate's profile opens, its first greeting lands in chat, and if you picked a workspace provider, the connect dialog opens straight away to finish the OAuth. ## The first day A **Setup progress** checklist appears on the new teammate's profile and walks you through making it reachable and useful: * **Break the ice** — say hi in chat, start a first voice call. * **Exchange emails** — connect its mailbox, have it send you something. * **Get on a call** — verify your number, give the teammate its own [phone number](/communication/phone-calls), and have it ring you. * **Give it access to your platforms** — connect the [integrations](/integrations/overview) its role needs, ideally through its own new workspace account. Beyond the checklist, two more first-day moves pay off: * **Add it to its [teams](/teams/overview)** so it starts with the unit's shared playbooks, knowledge, and credentials rather than a blank slate. * **Set a spending cap** if you want one — the **Monthly Spending** section on its profile takes a monthly credit limit (or Unlimited), and can be changed any time. Your T-W1N can do all of this for you. It can propose the colleague, create it, pre-seed it with decisions you've already made together, and hand over the follow-up work — hiring by conversation rather than by form. ## After the hire * **Edit freely** — name, role, About, timezone, voice, and appearance can all change later via **Edit profile**. Only the computer choice is locked. * **End contract** — hired teammates can be let go from the Edit dialog. It's permanent: *"You are about to remove from your team. This action cannot be undone."* Its channels are released and its data cleaned up. (Your T-W1N, by contrast, can't be removed — it's part of your account.) ## What hiring costs There's no hire fee. Costs come from what you provision and how much the teammate works: [dedicated channels](/communication/setup#what-channels-cost) (phone, WhatsApp) carry small setup and monthly credit costs, and the teammate's work consumes usage credits like any assistant — bounded by whatever spending cap you set. # Overview Source: https://docs.unify.ai/hiring/overview One private twin each — then dedicated teammates as the work grows The platform gives you two kinds of AI teammate, and knowing which to use when is the heart of building well: Created automatically for every person. Private to you, invisible to colleagues, and acts **as you** — through your accounts, with your access. You don't hire it; it's simply yours. Colleagues you onboard deliberately. Each has its **own identity** — its own workspace account, contact details, app profiles, and access — visible to and usable by the whole organization. ## The essential difference: whose identity? **T-W1N is a stand-in, not a separate person.** In its own words: > I'm here for you, specifically. When you connect your workspace, I act > through your accounts and show up as you, not as a separate identity on > the side. Emails it sends from your workspace come from *you*; files it touches are the ones *you* can reach; and it talks **only to you** — never to your colleagues or external contacts directly. Even its contact details are a platform-level routing layer rather than personal identity: shared numbers and addresses where incoming messages are matched to *your verified identity* and routed to *your* twin. **A hired teammate is a genuine new colleague.** It gets its own name, persona, and voice; [its own dedicated Google or Microsoft account](/hiring/twin-vs-teammates) with its own gated access to files and apps; its own phone number, email, and WhatsApp; its own profile on Teams and Slack; and its own integration credentials. Everyone in the org can find it, message it, and work with it — and it can [talk to anyone](/hiring/liaison), human or virtual, under the response policies you control. ## Start with twins, hire as you grow For most organizations the right sequence is: It's automatic, it's free of setup ceremony, and it piggybacks on each person's existing account — one OAuth connect and it's productive inside their inbox, calendar, and files. Individual productivity, solved. The signal is work that has a **defined scope and a shared audience**: multiple stakeholders need access, several people need to message the same assistant, or a process needs its own mailbox and file permissions rather than borrowing someone's. That's when a hired teammate with a dedicated account earns its keep. When you do hire, the dialog asks one ownership question up front: an **owning team** (the default — the teammate belongs to the team itself, everyone on it can work with it, and everything it learns is shared there), or **Personal** (it reports to you alone). The trade-offs are covered in [Team assistants](/teams/team-assistants). Your T-W1N actively helps with this judgment — it's briefed to spot the moment: > Sometimes a piece of work has outgrown a generalist and would be better > owned by a dedicated colleague — one defined scope, its own identity, its > own clock, a shared audience that isn't just you. When I see that shape, > I'll name it plainly and propose what the colleague would be, what they'd > own, and how we'd hand work to them. If you say yes, I set them up and > pre-seed them with what we've already decided. ## What's in this section The identity model in depth — accounts, access, contact details, and integrations. The hire flow: persona, voice, workspace account, computer — and life after hiring. How hired teammates work with everyone — people and assistants alike. # T-W1N vs hired teammates Source: https://docs.unify.ai/hiring/twin-vs-teammates Two identity models, side by side Every capability on the platform — [communication channels](/communication/overview), [workspace access](/workspace/overview), [integrations](/integrations/overview) — is available to both kinds of teammate. What differs is **whose identity it runs under**. That single difference cascades through everything. ## The workspace account **T-W1N connects to *your* account.** That's the point of a twin: it reads your inbox, checks your calendar, works in your Drive, and anything it sends from your workspace shows up as you. It borrows your access — and is therefore bounded by it. **A hired teammate gets a brand-new account of its own.** The hire flow is explicit about this: > Create a **new** Google or Microsoft account for , so they can join > your team, gain their own unique access controls to the files and > applications you use via **their own** new account, and can work > alongside your team. > > Do **not** connect to your own Google/Microsoft account. Only > T-W1N should have access to your personal account. This is where hired teammates earn their dedicated accounts: * **Gated access, managed like any employee's.** Your IT team shares exactly the drives, folders, and apps the teammate's role needs — no more. Its permissions are its own, auditable, and adjustable without touching anyone's personal account. * **A real presence in your org's tools.** With its own Microsoft 365 or Google identity, the teammate appears in Teams and Slack as *itself* — a named profile colleagues can DM, @mention, and add to channels — rather than as an extension of any one person. * **Its own mailbox and calendar.** Mail to the teammate lands in its inbox; meetings it schedules come from its calendar; threads it runs belong to it, and survive any individual employee's departure. ## Contact details **T-W1N's contact details are platform-managed** — there's nothing to configure. Its email, phone, and WhatsApp run on shared platform pools: when you message one, the platform recognizes *your* verified identity and routes the conversation to *your* twin. The same address serves everyone's twin — a routing layer, not a personal identity. **A hired teammate gets its own numbers and addresses**, provisioned in [Contact Details](/communication/setup): a dedicated phone number for calls and SMS, its own WhatsApp, its connected mailbox. When a client calls the repairs teammate's number, they reach *the repairs teammate* — whoever they are and whether or not they're on the platform at all. ## Integrations The same logic extends to [connected apps](/integrations/overview): * **T-W1N's** integrations naturally center on *your* accounts — its purpose is acting on your behalf inside your tools. * **A hired teammate's** integrations should be connected through **its own dedicated workspace account** wherever possible — the CRM seat, the ticketing login, the OAuth grants all belonging to the teammate itself. Its credential vault is its own, and access reviews treat it like any other member of staff. ## Visibility and lifecycle | | T-W1N | Hired teammate | | --------------------- | ------------------------------------------- | -------------------------------------------------------- | | Created | Automatically, for every person | Deliberately, via **Onboard** | | Visible to colleagues | Never — [private to you](/teams/membership) | Yes — listed, searchable, messageable | | Talks to | You only | [Anyone](/hiring/liaison), governed by response policies | | Name & look | Fixed | Your choice — name, persona, avatar, voice | | Workspace account | Yours | Its own, newly created | | Contact details | Platform-managed pools | Its own dedicated channels | | Team access | [Mirrors yours](/teams/membership) | Its own memberships | | Can be let go | No | Yes — **End contract** | ## Same brain, different passport It's worth repeating what *doesn't* differ: capability. The hire form says it plainly — > The bio doesn't influence the teammate's abilities. All teammates come > with the same foundational skills and can specialize in whichever area > you want them to. Every teammate learns, schedules, browses, and builds the same way. Choose between twin and hire based on **identity and audience** — who it acts as, and who it serves — not on what it can do. # Connecting apps Source: https://docs.unify.ai/integrations/connecting Browse the gallery, authorize, and manage your connected accounts Open your assistant's **Integrations** tab to see the app gallery. It's organized into three sections: * **Connected apps** — everything already wired up, pinned at the top and ready for your assistant to use. * **Needs attention** — apps that need a reconnect or configuration update before they'll work again. * **Available apps** — the full catalog, scrolling below. Use the search box or the filters — status (**Connected**, **Needs attention**, **Not connected**) and type (**Native**, **Third-party**) — to narrow things down, and check the footer for the running tally of what's connected. Each card wears badges telling you how it authorizes (**OAuth**, **API key**) and how many tools it brings. ## Connecting an app Click a card to see the details before connecting: a description, the **access scopes** the app will ask for, and the full list of **tools** your assistant would gain — so you know exactly what you're granting. You can add an optional label so the account is easy to recognize later — "Work Slack", "Personal Gmail", "Client Discord". Labels matter most when you connect more than one account to the same app. How you authorize depends on the app: * **OAuth** — a new tab opens with the app's own sign-in page. Approve, and you're returned to the Console. * **API key** — paste the key (or keys) into a short form; the connection is live immediately. Right after connecting, the app's tools take a moment to sync — your assistant will mention if they're still warming up ("Slack tools are syncing and will be available shortly") and again when they're ready. Not sure how to get an API key, or where an app's settings live? Ask your assistant. Guiding you through app setup — ideally on a screen-share call — is one of the things it does best. Credentials are never shared through chat; everything sensitive goes through the Integrations tab. ## Multiple accounts per app You can connect the same app more than once — a work HubSpot and a client HubSpot, say. Each connected account: * has its own label (rename any time), * has its **own tool permissions** (see [Permissions & security](/integrations/permissions)), * can be tested, reconnected, or disconnected independently. Use **Add account** in the app's detail view to connect another. ## Keeping connections healthy Each account row shows its health — **Healthy** or **Needs attention** — and you can hit **Test** any time to check a connection. The card and account badges tell you exactly where things stand: | Badge | Meaning | | ----------------------------------- | ------------------------------------------------------------------------- | | **Connected** / **Configured** | Working — authorized (OAuth) or credentialed (API key) | | **Connecting** | Authorization in progress | | **Needs attention** / **Reconnect** | An expired login, revoked grant, or missing permission — reconnect to fix | | **Needs setup** | Something must be configured before connecting (see below) | | **Not connected** | Available in the catalog, not yet linked | Disconnecting removes the authorization for that assistant; you can reconnect later if you need the app again. For apps connected with your own OAuth app credentials, disconnecting keeps the client details saved so reconnecting is one click. ## Bring your own OAuth (admins) Most OAuth apps connect with platform-managed credentials — click Connect and go. For organizations that want more control, each app's detail view has a **Bring your own OAuth** section (admin-only) where you can register your organization's own OAuth app instead. Reasons to bother: * **Your branding on the consent screen** — people authorizing see your organization's app, not a generic one. * **Custom scopes** — request exactly the permissions your workflows need. * **Apps without managed credentials** — a few catalog apps can *only* be connected this way. Their cards say so ("Custom OAuth app required") and stay unconnectable until an admin sets one up. Setup is two steps: copy the **authorized redirect URI** shown in the Console into your provider's developer portal, then paste in your app's client ID and secret. The client secret is stored in the provider's secure vault — never by the platform itself — and the configuration can be removed again at any time. ## Apps not in the gallery For services the gallery doesn't cover, there's a **Custom secret** option: store any key/value credential (like `stripe/prod/API_KEY`) with your assistant, and it can use that credential to work with the service's API directly. See [Using connected apps](/integrations/using) for how that plays out in practice. # Developers Source: https://docs.unify.ai/integrations/developers How integrations actually work inside the open-source unify runtime This page is for engineers reading, extending, or debugging the integrations machinery in the open-source [`unifyai/unify`](https://github.com/unifyai/unify) repo. Everything above this page describes what users experience; this page describes how the runtime makes it happen — module by module, with the real symbol names. The hosted control plane (connection storage, tool policies, audit, provider dispatch) lives in the closed-source Orchestra backend, reached over HTTPS. The `unify` runtime owns everything on the assistant's side of that line: discovery, tool materialization, execution wrappers, sync lifecycle, and the prompts that teach the actor when to use what. ## The big picture: three lanes There is no single "integrations pathway". Three lanes coexist, each with its own discovery, activation, and execution story — but they all converge on one rule: **FunctionManager search is the single discovery surface for executable tools**. Whatever lane a capability arrives through, the actor finds it the same way it finds everything else. Three integration lanes: provider-backed third-party apps discovered via the Builtins catalog and executed through Orchestra; native unity-deploy packages activated by secrets; and the SDK-plus-secrets pattern for apps with no gallery entry — all converging on FunctionManager search Three integration lanes: provider-backed third-party apps discovered via the Builtins catalog and executed through Orchestra; native unity-deploy packages activated by secrets; and the SDK-plus-secrets pattern for apps with no gallery entry — all converging on FunctionManager search | Lane | Discovery | Activation | Execution | | ------------------- | ----------------------- | --------------------------- | --------------------------------------------------------------- | | **Provider-backed** | Builtins catalog | Console connect + tool sync | `primitives.integrations..` → Orchestra governed run | | **Native packages** | Disk manifest discovery | Required secrets present | Custom function rows in `Functions/Compositional` | | **SDK + secrets** | None (actor-authored) | User-provided credentials | `execute_code` calling the service's API directly | The rest of this page walks each lane, then the shared plumbing. ## The `unify.integrations` package Everything provider-backed funnels through [`unify/integrations/`](https://github.com/unifyai/unify/tree/main/unify/integrations), a deliberately thin package with sharply separated responsibilities. ### `primitives.py` — the actor-facing surface [`unify/integrations/primitives.py`](https://github.com/unifyai/unify/blob/main/unify/integrations/primitives.py) defines `IntegrationPrimitives`, mounted at `primitives.integrations` in the actor sandbox. Its design principle, from the class docstring: > Actor-facing discovery is intentionally app-only: native Unity-deploy > packages and third-party provider apps are searched through Orchestra's > global app catalog, then enriched with current-assistant state in Unity. > Executable functions/tools are still discovered through normal > FunctionManager search after an app is active and materialized. Four methods are indexed in FunctionManager as regular primitives: * **`search_integrations(query, ...)`** — app-level discovery across both native and third-party sources. Each result carries the fields an agent needs to reason about readiness: `canonical_app_slug`, `source_type` (`native` | `third_party`), `connection_status`, `sync_status` (`not_enabled` → `missing_required_secrets` → `not_connected` → `not_yet_synced` → `materialized`), and a `next_action` hint like *"Ask the user to connect this integration in Console Integrations."* * **`review_tool_permissions(connection_id)`** and **`update_tool_permissions(...)`** — read and patch the per-connection tool policy (allow / ask-every-time / block, plus bulk presets by action class). * **`resolve_tool_execution(audit_id, decision, ...)`** — approve or deny a pending sensitive-tool execution, optionally persisting the decision as policy. A second set of methods (`execute_tool`, `list_connected`, `search_tools`, `get_tool_schema`, `manage_connection`, `callable_for_tool`, …) is deliberately **excluded from FunctionManager indexing** via the `ToolSurfaceRegistry` entry in [`unify/function_manager/primitives/registry.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/registry.py) — they're runtime plumbing, not tools the actor should discover. The magic dot-path — `primitives.integrations.hubspot.search_contacts(...)` — is implemented by `__getattr__` on `IntegrationPrimitives`: any unknown attribute returns an `_IntegrationAppNamespace`, whose own `__getattr__` resolves the tool name against the materialized FunctionManager row and returns an async closure over `execute_tool`. ### `ops.py` — the transport seam [`unify/integrations/ops.py`](https://github.com/unifyai/unify/blob/main/unify/integrations/ops.py) is the only place the runtime touches the network for integrations, and it keeps itself honest: > This module is intentionally not a raw HTTP client and it intentionally > does not wrap the helpers in a stateful client object. Unity calls these > small functions, the functions call Unify, and Unify owns the Orchestra > route mapping, base URL normalization, auth headers, and payload shape. One flat function per operation — `list_connections`, `run_tool`, `get_tool_policy`, `patch_tool_policy`, `approve_tool_execution`, `deny_tool_execution`, `test_connection` — each delegating to the UniSDK HTTP helper of the same shape and normalizing failures into a uniform `{status: "error", error: {...}}` envelope. If you're tracing a request, this is the seam: above it is pure runtime logic, below it is UniSDK → Orchestra. ### `builtins_catalog.py` — the shared app/tool catalog [`unify/integrations/builtins_catalog.py`](https://github.com/unifyai/unify/blob/main/unify/integrations/builtins_catalog.py) reads and writes the platform-wide **Builtins** catalog — three log contexts in the public-read Builtins project: | Context | Contents | | -------------------- | ------------------------------------------------------------------------------------------- | | `Integrations/Apps` | One row per app: slug, display name, description, auth modes, `source_type`, embedding text | | `Integrations/Tools` | One row per provider tool, pre-shaped as a FunctionManager row | | `Integrations/Meta` | Seed hashes (`integration_catalog_hash_by_unit`) for idempotent reseeding | `seed_builtin_integrations(...)` reconciles the catalog hash-by-hash (units keyed `app:{backend}:{slug}` / `tools:{backend}:{slug}`), so reseeding is cheap and prunable. `list_catalog_apps(query)` powers semantic app search (via the row's embedding text); `list_catalog_tools(canonical_app_slug)` is what materialization reads. The catalog is populated by [`scripts/seed_builtins_catalog.py`](https://github.com/unifyai/unify/blob/main/scripts/seed_builtins_catalog.py), which ships inside the unify application image. Given an integration-bootstrap manifest (TOML, `providers.*` sections with a `sync` block — `mode: partial|full`, `app_slugs`, `tool_limit_per_app`, `prune_unlisted_apps`, …), it registers provider backends with Orchestra, runs the catalog sync, and seeds the three contexts. Deployments differ only in their manifest: a partial three-app staging sync and a full production sync are the same code path. ## Provider tools: materialization and sync Connecting an app in the Console doesn't directly give the actor anything. What it triggers is **materialization**: provider tools from the Builtins catalog are written into the assistant's own `Functions/Primitives` context as first-class FunctionManager rows, so ordinary FM search finds them. Provider tool sync and materialization: a Console connect emits an integration_tools_sync_requested event, the IntegrationSyncCoordinator marks the app as syncing, FunctionManager.sync_provider_integration_tools reads the Builtins tools catalog, compares per-app hashes, upserts changed rows into Functions/Primitives, and the user is notified the tools are ready — with a cleanup path on disconnect and a startup pass over all connected apps Provider tool sync and materialization: a Console connect emits an integration_tools_sync_requested event, the IntegrationSyncCoordinator marks the app as syncing, FunctionManager.sync_provider_integration_tools reads the Builtins tools catalog, compares per-app hashes, upserts changed rows into Functions/Primitives, and the user is notified the tools are ready — with a cleanup path on disconnect and a startup pass over all connected apps ### The materialized row `FunctionManager._integration_tool_to_function_row` in [`unify/function_manager/function_manager.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/function_manager.py) shapes each tool into a primitive row with a stable identity: * **Callable name**: `primitives.integrations..` * **Stable `tool_id`**: `{backend_id}:{app_slug}:{tool_name}` * **`function_id`**: derived from a SHA-256 of the tool identity, so re-syncs are idempotent * **`implementation: None`** — there is no stored Python source; execution dispatches through `IntegrationPrimitives.callable_for_tool` at runtime * **`metadata.source: "provider_backed"`** and a `metadata.integration` payload (tool id, backend, app slug, input schema, `action_class`, `confirmation_required`) — accessors for all of this live in [`unify/integrations/function_metadata.py`](https://github.com/unifyai/unify/blob/main/unify/integrations/function_metadata.py) * **`verify: True`** is set automatically for tools that are confirmation-gated or whose action class is `write`, `destructive`, or `bulk_export` ### The sync algorithm `FunctionManager.sync_provider_integration_tools` is an explicit sync path, not query-time magic. Per its docstring: > Materialize active provider-backed tools into the Primitives context. > This is an explicit sync path, not a FunctionManager query-time search. > It builds expected rows, compares stable per-app hashes, and only > deletes/upserts the affected app rows when changed. It lists the assistant's connections, filters to `status == "connected"`, reads the expected tools from `Integrations/Tools`, groups them by `{backend_id}:{app_slug}`, and compares against per-app hashes stored in the assistant's `Functions/Meta` context (`integration_tool_hash_by_app`). Only changed apps get their rows deleted and re-inserted. A `cleanup` operation handles disconnects: when an app's last connection is gone, its rows are removed. ### The lifecycle coordinator [`unify/integrations/sync_state.py`](https://github.com/unifyai/unify/blob/main/unify/integrations/sync_state.py) owns the in-memory lifecycle: `IntegrationSyncCoordinator` tracks per-app `IntegrationSyncState` (`pending` → `syncing` → `ready` | `failed` | `removed`), spawns the actual FM sync on a worker thread, and renders a `prompt_summary()` — the `` block injected into the ConversationManager's state message so the assistant *knows* which apps are mid-sync and can defer using them. Two things trigger it: 1. **Startup** — `schedule_connected_apps()` runs when a session boots, reconciling all connected apps (usually a no-op thanks to the hashes). 2. **Live events** — the Console/Orchestra side emits an `integration_tools_sync_requested` system event when a user connects or disconnects an app. The ConversationManager maps this to an `IntegrationToolsSyncRequested` event (see [`unify/conversation_manager/events.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/events.py)), and the handlers in [`unify/conversation_manager/domains/integration_sync.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/integration_sync.py) drive the coordinator and surface the user-facing notifications — *" tools are syncing and will be available shortly."* → *" tools are ready."* One naming trap: the onboarding milestone event `integration_connected` (fired when a user saves an integration during onboarding) is a **conversational** event only — it does not trigger tool sync. The sync trigger is always `integration_tools_sync_requested`. ## Execution and governance At call time, the runtime is a thin, honest client — every policy decision happens server-side, and every non-`ok` outcome comes back as a typed status the actor is prompted to handle rather than swallow. Execution and governance: actor sandbox code calls the dynamic integration namespace, which resolves execute_tool, flows through ops.run_tool and UniSDK to Orchestra's governed execution — policy check, audit record, provider dispatch — returning a result envelope whose statuses include ok, connect_required, missing_scope, blocked_by_policy, and confirmation_required, the last of which loops through a pending-approval payload and resolve_tool_execution retry Execution and governance: actor sandbox code calls the dynamic integration namespace, which resolves execute_tool, flows through ops.run_tool and UniSDK to Orchestra's governed execution — policy check, audit record, provider dispatch — returning a result envelope whose statuses include ok, connect_required, missing_scope, blocked_by_policy, and confirmation_required, the last of which loops through a pending-approval payload and resolve_tool_execution retry The result envelope statuses form the contract: | Status | Meaning | Actor's obligation | | ----------------------- | ---------------------------------- | ---------------------------------------------- | | `ok` | Tool ran; payload attached | Use the result | | `connect_required` | No live connection | Tell the user to connect the app in Console | | `missing_scope` | Connection lacks a permission | Ask the user to reconnect with expanded access | | `expired` / `error` | Auth or provider failure | Surface it; suggest reconnect/test | | `blocked_by_policy` | Tool set to Block for this account | Explain; point to tool permissions | | `confirmation_required` | Tool needs human sign-off | Enter the approval flow below | ### The approval loop When Orchestra returns `confirmation_required`, [`unify/integrations/approval.py`](https://github.com/unifyai/unify/blob/main/unify/integrations/approval.py)'s `build_pending_approval_payload` reshapes the provider envelope into an `integration_tool_pending_approval` notification: an `approval` block (audit id, behavior hints, argument summary) for presenting the decision to the user, and a `resume` block carrying everything needed to retry. The user decides; `resolve_tool_execution(audit_id, decision)` records it (optionally persisting a standing policy), and the original call is retried with the confirmation token. The [`CodeActActor`](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py) wires this into its notification queue so approvals ride the same channel as every other mid-action interaction. ### Where execution is dispatched Two paths converge on the same closure: * **Dynamic namespace** — `primitives.integrations..(...)` in sandbox code resolves through `callable_for_app_tool`. * **`execute_function`** — when the actor executes a discovered FM row, `get_primitive_callable` in [`unify/function_manager/primitives/runtime.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/runtime.py) detects `is_provider_backed_function` and routes to `IntegrationPrimitives.callable_for_tool` instead of a normal `primitive_method` lookup (there is no stored implementation to run). ## Native packages: `unify.integration_status` The second lane has nothing to do with providers. **Native integration packages** are code-and-guidance bundles (shipped in the private unity-deploy repo) that the open runtime discovers and activates locally. [`unify/integration_status/discovery.py`](https://github.com/unifyai/unify/blob/main/unify/integration_status/discovery.py) is the source of truth for what exists: > This module enumerates every manifest under unity\_deploy's package roots > and projects each one into a lightweight metadata record. It is the > **single source of truth** for "what integration packages exist" at > runtime. `discover_available_packages()` returns one record per package: slug, label, `required_secrets` / `optional_secrets`, `function_names`, and `guidance_titles`. [`unify/integration_status/__init__.py`](https://github.com/unifyai/unify/blob/main/unify/integration_status/__init__.py) then runs two decoupled mechanisms: 1. **Registration** (startup) — `register_available_integrations()` walks discovered packages whose secrets are satisfied and registers their custom functions into `Functions/Compositional` and their guidance into the GuidanceManager. 2. **Enablement** (query time) — `get_enabled_integrations()` computes which packages are live (`required_secrets` all present, or at least one `optional_secret`), and `build_function_filter_scope()` / `build_guidance_filter_scope()` hide a *disabled* package's functions and guidance from search entirely. An inactive integration doesn't error — it simply doesn't exist from the actor's perspective, and `search_integrations` reports it as `missing_required_secrets` with the secrets to add. ## The escape hatch: SDK + secrets The third lane has no catalog at all. For services outside the gallery, the actor is prompted (the `_EXTERNAL_APP_INTEGRATION` block in [`unify/actor/prompt_builders.py`](https://github.com/unifyai/unify/blob/main/unify/actor/prompt_builders.py)) to follow a disciplined pattern: 1. **Check credentials first** — `primitives.secrets.ask(...)` inspects the secret vault by name and placeholder only; raw values never enter the prompt. Missing credentials → direct the user to the Console Integrations tab, never ask for keys in chat. 2. **Install the official SDK** and integrate in `execute_code`, reading credentials from the environment. 3. **For Google/Microsoft OAuth**, use `get_oauth_access_token(provider)` from [`unify/common/runtime_oauth.py`](https://github.com/unifyai/unify/blob/main/unify/common/runtime_oauth.py) — it returns a local capability handle, not a raw token; a local proxy injects the real token and enforces the file-access allowlist, so sandbox code can never exfiltrate credentials. 4. **Persist what worked** — store the pattern as a reusable function and guidance entry so the next similar job skips the exploration. ## Extending the system **Adding a provider-backed app** is configuration, not code: ensure the app exists in the provider's catalog, add it to the bootstrap manifest, run `seed_builtins_catalog.py`, and the entire connect → sync → materialize → execute pipeline works with zero new runtime code. Optionally add prompt examples in [`unify/actor/prompt_examples.py`](https://github.com/unifyai/unify/blob/main/unify/actor/prompt_examples.py). **Shipping a native package** means writing a manifest (secrets + capabilities), functions, and guidance stems in a package directory — discovery, gating, and registration are automatic from there. **Debugging a failing tool call**, from the outside in: 1. Is the app materialized? `search_integrations` → `sync_status`, or filter `Functions/Primitives` for `metadata.source == "provider_backed"`. 2. Is the connection healthy? `list_connected`, or **Test** in the Console. 3. What did execution return? The envelope status names the failure mode precisely — most "failures" are `connect_required` / `missing_scope` / `blocked_by_policy` states with a user-facing remedy. 4. Cross the seam: UniSDK request logs and Orchestra traces pick up where the runtime hands off. ## Test map The behaviors above are pinned by tests worth reading as executable documentation: | Area | Tests | | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `IntegrationPrimitives` surface & dynamic namespaces | [`tests/integrations/test_integration_primitives.py`](https://github.com/unifyai/unify/blob/main/tests/integrations/test_integration_primitives.py) | | Sync coordinator & CM handlers | [`tests/integrations/test_integration_sync_state.py`](https://github.com/unifyai/unify/blob/main/tests/integrations/test_integration_sync_state.py) | | Row shaping, hashing, materialization | [`tests/function_manager/test_provider_integration_materialization.py`](https://github.com/unifyai/unify/blob/main/tests/function_manager/test_provider_integration_materialization.py) | | Native package discovery & enablement | [`tests/test_integration_status/`](https://github.com/unifyai/unify/tree/main/tests/test_integration_status) | | OAuth proxy | [`tests/common/test_runtime_oauth.py`](https://github.com/unifyai/unify/blob/main/tests/common/test_runtime_oauth.py) | # Overview Source: https://docs.unify.ai/integrations/overview Connect the apps your team already uses, and your assistant works inside them Real work happens inside apps — your CRM, your project tracker, your knowledge base, your code repos. **Integrations** connect those apps to your assistant so it can act through them: look up a customer in HubSpot, file a Jira ticket, update a Notion page, check a GitHub pull request. This all lives in the **Integrations** tab (the plug icon) on your assistant's page in the Console — the home of everything your teammate can act through: mail, calendar, CRM, and more. The tab is built around a simple loop: **browse the catalog** (search or filter to find an app), **review permissions** (open a card to see exactly what the teammate would access), and **connect** (authorize with OAuth or paste an API key). Connecting takes under a minute and costs nothing — there are no credit charges for integrations. Each assistant has its own Integrations tab and its own connections — connecting HubSpot to your reporting teammate doesn't connect it to anyone else. Shared access flows through [teams](/teams/shared-context) when you want it. ## What connecting an app unlocks Once an app is connected, you just ask in plain language: * *"Any new HubSpot leads since Friday?"* * *"Create a Linear issue for the login bug and assign it to Sam."* * *"Summarize what changed in the Notion project doc this week."* * *"Post the weekly numbers to the #metrics channel."* Your assistant translates the request into the right actions in the right app — governed by [permissions you control](/integrations/permissions), with confirmation required for sensitive changes. ## The kinds of apps you'll see Every app card in the gallery is labeled with its type: * **Third-party** — the broad catalog of SaaS apps (CRM, project management, files, engineering tools, and more). You connect these yourself with a sign-in or an API key. * **Native** — integrations built and operated by the platform, often tailored for a specific organization. These show a **View** button rather than Connect — they're enabled as part of your deployment. The catalog varies by organization, and it's large — search is the fastest way to find out whether an app is supported. If something you need isn't there, your assistant can often still work with it (see [Using connected apps](/integrations/using)). ## How integrations relate to everything else The platform draws clear lines between three things that all involve "connecting accounts": | | Where it lives | What it's for | | ------------------------------------------------- | ---------------- | ------------------------------------------------------------- | | **Integrations** | Integrations tab | Apps your assistant *acts through* — CRM, tickets, docs, code | | [Workspace](/workspace/overview) | Workspace dialog | The Google/Microsoft suite — mailbox, calendar, Drive files | | [Communication channels](/communication/overview) | Contact Details | How you and your assistant *talk* — phone, WhatsApp, email | One example that trips people up: **Slack appears twice**, wearing different hats. Connecting Slack as a [communication channel](/communication/slack) lets your assistant chat in your workspace as itself. Connecting Slack in the Integrations gallery lets your assistant act in *your* Slack account — reading channels, posting as part of automations. You can use either or both. ## What's in this section The gallery, OAuth and API-key flows, account labels, and multiple accounts. Per-tool permissions, approval for sensitive actions, and how credentials are protected. Asking in plain language, recurring tasks, and apps that aren't in the gallery. # Permissions & security Source: https://docs.unify.ai/integrations/permissions You decide exactly what your assistant can do in each app Connecting an app doesn't mean handing over the keys. Every connected account comes with a permission system that puts you in control of which actions your assistant can take — and which ones need your sign-off first. ## Per-tool permissions Each app exposes a set of **tools** — the individual actions your assistant can perform (search contacts, send a message, delete a record…). For every tool, per connected account, you choose one of three policies: | Policy | Meaning | | ------------------ | ----------------------------------------------------- | | **Allow** | The tool runs when needed, without asking | | **Ask every time** | Your assistant must get your approval before each use | | **Block** | The tool is disabled entirely | Tools are tagged so you can see at a glance what they do: **Can change data**, **Destructive**, **Sensitive data**, **Bulk data**. Risky tools default to needing confirmation. ### Presets Rather than setting dozens of tools one by one, apply a preset to the whole account: * **Allow read tools** — read-only tools run freely; anything that changes data asks first. * **Confirm write actions** — ask before tools can change data. * **Block all tools** — disable everything, then re-enable just what you want. * **Reset all to defaults** — back to the recommended policy. If you've connected multiple accounts to one app, each account has its own policy — your assistant might have free rein in a sandbox account but read-only access in production. ## Approvals in practice When your assistant hits a tool set to **Ask every time**, it pauses and asks you before acting — "I'm ready to update these 14 HubSpot records, shall I go ahead?" — and proceeds only on your yes. Every integration action is also recorded, so there's always an answer to "what did the assistant actually do in that app?" ## How credentials are handled * Sign-ins happen with the app itself (OAuth) — the platform never sees your password, and API keys are stored in secure credential storage, not in conversation history. * Credentials are kept **outside prompts** entirely: they're only used at the moment your assistant invokes an allowed tool, and even the code your assistant writes can't read the raw values. * Your assistant will never ask you to paste a password or API key into chat, or to read one aloud on a call. Anything sensitive goes through the Integrations tab. ## Scopes: what the app grants Before you connect, the app's detail view lists the **access scopes** it will request — what the app may read or change. If your assistant later needs a permission you didn't grant, it won't fail silently: it tells you more access is needed and points you to **Reconnect**, where you approve the expanded permissions explicitly. ## Revoking access Disconnect from the Integrations tab at any time, and the authorization is removed immediately. For OAuth apps you can also revoke from the app's own side (its connected-apps or security settings) — both routes kill access. # Using connected apps Source: https://docs.unify.ai/integrations/using Just ask — your assistant handles the rest Once an app is connected, there's nothing to learn. You don't pick tools or build workflows — you ask for outcomes, and your assistant figures out which apps and actions get you there. ## Ask in plain language * *"Pull this week's new signups from HubSpot and give me the highlights."* * *"File a GitHub issue for the crash Priya reported, with the log attached."* * *"Move the launch tasks in Linear to next sprint."* * *"What did the team decide in Notion about pricing?"* Requests can span apps, too: "check the new leads in HubSpot and post a summary to Slack" is one instruction, not two. ## Put apps on a schedule Connected apps really pay off in [recurring tasks](/tasks/overview). Describe the routine once and your assistant runs it on schedule: * *"Every Monday at 9, summarize open Jira tickets by priority and email me."* * *"Each morning, check HubSpot for new leads and message me the ones worth a call."* Scheduled work uses the same permissions as everything else — tools set to **Ask every time** still wait for your approval. ## When something's missing Your assistant is upfront about gaps rather than guessing: * **App not connected?** It tells you, and points you to the Integrations tab — then carries on with whatever it *can* do in the meantime. * **Tools still syncing?** Right after you connect an app, it may say the tools are syncing and will be available shortly. * **Missing permission?** If an action needs a scope you didn't grant, it explains and asks you to reconnect the app with the extra access. ## Apps without a gallery entry The gallery is the easy path, but it isn't the boundary of what your assistant can reach: * **Any service with an API.** Store a credential as a [custom secret](/integrations/connecting#apps-not-in-the-gallery) and your assistant can work with the service's API directly — it can integrate with virtually any service that offers one. * **Any app with a screen.** Your assistant has [its own computer](/their-computer/overview) and browser. For apps with no API at all, it can work the same way you would — signing in and clicking through the interface. ## During onboarding Connecting your first app is one of the onboarding checklist steps — clicking the row opens the Integrations tab, and connecting any one app from the gallery completes it. Your assistant narrates as you go, and if you'd rather be shown than told, it will happily walk you through on a screen-share call. A good first app is the one your day already revolves around — your CRM, your ticket tracker, or your team's knowledge base. Connect it, then ask your assistant a question you'd normally dig for manually. # Welcome Source: https://docs.unify.ai/introduction Meet your AI assistant Unify — everything you can do with your AI teammate Unify — everything you can do with your AI teammate Unify gives you a fully remote virtual employee — an AI assistant you can talk to the way you'd talk to any other colleague. Message it, email it, call it, or hop on a video call and share your screen. It remembers your conversations, works on tasks in the background, and reaches out when it has something for you. These docs explain everything your assistant can do, and how to get the most out of working together. ## Start here Every way to reach your assistant — chat, calls, email, WhatsApp, SMS, Slack, Teams, Discord, and more. Connect Google Workspace or Microsoft 365 — inbox, calendar, files, contacts, and tasks. Connect your CRM, project tracker, and other apps so your assistant can act through them. Scheduled, recurring, and event-triggered work your assistant runs while you're away. Correct your assistant once and the fix sticks — playbooks, skills, and watching it work. Live data tables and custom dashboards your assistant builds on request. Every teammate has its own Ubuntu or Windows desktop — watch it work live, or take the controls. Link your own Mac, Windows, or Ubuntu machine so your assistant can work on it directly. Group people and assistants into units that share knowledge, skills, and tasks — with tight boundaries. Your private T-W1N versus dedicated teammates with their own accounts, identities, and audiences. Run the open-source runtime on your own machine — one teammate, fully traceable, against the hosted backend. # Architecture Source: https://docs.unify.ai/learning/developers/architecture How learning actually works in the open-source unify runtime This sub-section documents the learning machinery in the open-source [`unifyai/unify`](https://github.com/unifyai/unify) repo for developers reading, extending, or embedding the code. It covers the two skill libraries, the actor loop that reads and writes them, and the knowledge and memory subsystems — with symbol names and links into the source throughout. The learning loop: ConversationManager dispatches act() to CodeActActor, whose Phase 1 doing loop is gated on FunctionManager and GuidanceManager discovery and whose Phase 2 StorageCheck writes back to both libraries The learning loop: ConversationManager dispatches act() to CodeActActor, whose Phase 1 doing loop is gated on FunctionManager and GuidanceManager discovery and whose Phase 2 StorageCheck writes back to both libraries ## The dual library Everything the assistant "learns" lands in one of two complementary stores, each a state manager with an abstract contract (`base.py`) and an Orchestra-backed implementation: | | [`FunctionManager`](https://github.com/unifyai/unify/blob/main/unify/function_manager/function_manager.py) | [`GuidanceManager`](https://github.com/unifyai/unify/blob/main/unify/guidance_manager/guidance_manager.py) | | ----------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Stores | The **what** — executable functions (Python/shell), plus a searchable catalog of platform primitives | The **how** — procedural prose: SOPs, walkthroughs, composition recipes, pitfalls | | Row model | `Function` ([types/function.py](https://github.com/unifyai/unify/blob/main/unify/function_manager/types/function.py)) | `Guidance` ([types/guidance.py](https://github.com/unifyai/unify/blob/main/unify/guidance_manager/types/guidance.py)) | | Contexts | `Functions/Compositional`, `Functions/Primitives`, `Functions/VirtualEnvs`, `Functions/Meta` | `Guidance` (+ `Guidance/Meta` for builtins hashes) | | Cross-links | `Function.guidance_ids` | `Guidance.function_ids` | The two are deliberately linked: a stored workflow is typically a compositional **function** (the building block) plus a **guidance** entry that references it via `function_ids` (the recipe that says when and how to compose it). ## The two-phase `act()` The learning loop lives in [`CodeActActor`](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py). Every `act()` call is potentially two phases wrapped in one handle: 1. **Phase 1 — the doing loop.** An async tool loop (`start_async_tool_loop`) whose tool surface includes `execute_code`, `execute_function`, the `FunctionManager_*` and `GuidanceManager_*` tools, `request_clarification`, and (when storage is enabled) `store_skills`. The `ConversationManager` dispatches into this via its `act` tool and receives a `SteerableToolHandle`. 2. **Phase 2 — StorageCheck.** When `can_store` is in effect (or a `PostRunReviewContext` is present), the handle returned by `act()` is a `_StorageCheckHandle` that runs a **separate** "skill librarian" LLM loop after the doing loop completes — reviewing the trajectory and deciding what, if anything, persists. Covered in depth in [StorageCheck](/learning/developers/storage-check). The handle's semantics are the key design point: `result()` resolves at the end of **Phase 1**, so callers get the task's answer without waiting for storage; `done()` is true only after Phase 2 finishes. Learning never adds latency to answers. ## The discovery-first gate Before the doing agent can execute anything, it must consult both libraries. This is enforced mechanically, not just prompted: * `CodeActActor._default_tool_policy()` returns a `ToolPolicyFn` that, on each turn, checks whether any `FunctionManager_`-prefixed and any `GuidanceManager_`-prefixed tool has been called. Until **both** are satisfied, the loop exposes *only* the unsatisfied discovery tools with `tool_choice="required"`. * The companion prompt constants — `_DISCOVERY_FIRST_POLICY` and `_FUNCTION_AND_GUIDANCE_LIBRARY` in [prompt\_builders.py](https://github.com/unifyai/unify/blob/main/unify/actor/prompt_builders.py) — explain the contract to the model, and [prompt\_examples.py](https://github.com/unifyai/unify/blob/main/unify/actor/prompt_examples.py) ships an explicit anti-pattern example ("search is not permission to jump into custom code"). A subtle but important mechanic: the search wrappers call the manager with `_return_callable=True`, so discovered functions are **injected into the sandbox namespace** (registered via `SessionExecutor.register_fm_globals`) while the LLM sees only metadata. Discovery isn't advisory — it arms the sandbox. ## Scopes: personal, team, builtins Both libraries (and Knowledge) read across every scope the assistant can see and write to exactly one: Federated scopes: federated_ranked_search fans out to the personal root, team roots, and the read-only builtins catalogue, with ContextRegistry resolving read and write roots Federated scopes: federated_ranked_search fans out to the personal root, team roots, and the read-only builtins catalogue, with ContextRegistry resolving read and write roots * [`ContextRegistry.read_roots`](https://github.com/unifyai/unify/blob/main/unify/common/context_registry.py) returns the personal root first, then `Teams/{team_id}/…` for each id in `SESSION_DETAILS.team_ids` (for tables listed in `SHARED_SCOPED_TABLES` in [authorship.py](https://github.com/unifyai/unify/blob/main/unify/common/authorship.py)). * [`federated_ranked_search`](https://github.com/unifyai/unify/blob/main/unify/common/federated_search.py) fans a semantic query out across one `FederatedSearchContext` per root — plus the **builtins catalogue** on the public platform project (passed via the `project` field) — and merges results globally by score. * `ContextRegistry.write_root` maps a `destination` (`"personal"` or `"team:"`) to a single concrete context, raising a `ToolErrorException` (`error_kind: invalid_destination`) when the assistant isn't a member of the named team. Tenant writes never target the builtins project. ## Embeddings Semantic search is backed by Orchestra-side derived vector columns, created lazily and shared by all scopes: * [`ensure_vector_column`](https://github.com/unifyai/unify/blob/main/unify/common/embed_utils.py) issues a derived-column definition using `embed(...)` with `EMBED_MODEL = "text-embedding-3-small"`. * Functions embed a synthesized `embedding_text` field (name + signature + docstring) into `_embedding_text_emb`; guidance embeds `content` into `_content_emb` (builtins additionally embed `title`). Each manager's `warm_embeddings()` pre-creates its columns; searches also create them on demand via `ensure_vector_for_source` in [semantic\_search.py](https://github.com/unifyai/unify/blob/main/unify/common/semantic_search.py). ## Map of this sub-section The executable library: primitives, compositional functions, venvs, custom sync, and execution. The procedural library: CRUD semantics, previews, images, builtins. The post-run learning pass, store\_skills, and task entrypoint certification. KnowledgeManager's NL tool loops and MemoryManager's background consolidation. # FunctionManager Source: https://docs.unify.ai/learning/developers/function-manager The executable library: primitives, compositional functions, venvs, and execution [`FunctionManager`](https://github.com/unifyai/unify/blob/main/unify/function_manager/function_manager.py) maintains the catalog of everything the actor can execute. Its abstract contract lives in [base.py](https://github.com/unifyai/unify/blob/main/unify/function_manager/base.py) (`BaseFunctionManager`), with a simulated in-memory twin in [simulated.py](https://github.com/unifyai/unify/blob/main/unify/function_manager/simulated.py) for tests. ## The `Function` model Defined in [types/function.py](https://github.com/unifyai/unify/blob/main/unify/function_manager/types/function.py). There is no `function_type` enum — primitive vs. compositional is expressed by storage context plus the `is_primitive` flag. Fields worth knowing: | Field | Role | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `name`, `argspec`, `docstring` | Identity and signature; primitives use qualified names like `primitives.contacts.ask` | | `implementation` | Source code; `None` for primitives (their code lives in the platform classes) | | `language` | `python`, `bash`, `zsh`, `sh`, or `powershell` | | `depends_on` | Dependency names — bare compositional names or dotted primitive names | | `embedding_text` | The synthesized text embedded for semantic search (`Function Name: … / Signature: … / Docstring: …`) | | `guidance_ids` | Inverse link to related `Guidance` rows | | `verify` | Consumed by verification flows (e.g. `SingleFunctionActor._verify_execution`) — a failed verification lets the actor reimplement and overwrite | | `venv_id` | FK to a `VirtualEnv` row for third-party dependencies | | `windows_os_required` | Routes execution to the Windows VM when the assistant's `desktop_mode` is `windows` | | `custom_hash` | Sync fingerprint for source-defined custom functions | ## Storage contexts Four contexts, wired with foreign keys and auto-counting in `FunctionManager.Config.required_contexts`: * **`Functions/Compositional`** — user/agent-authored functions (auto-increment `function_id`). * **`Functions/Primitives`** — per-assistant rows for *provider-backed integration tools* only; static platform primitives live in the global read-only builtins catalogue instead. * **`Functions/VirtualEnvs`** — `VirtualEnv` rows; many functions can share one `venv_id`. * **`Functions/Meta`** — a singleton `FunctionsMeta` row holding sync hashes (`custom_functions_hash`, `integration_tool_hash_by_app`, …). ## Where primitives come from The single source of truth is [`ToolSurfaceRegistry`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/registry.py): a list of `ManagerSpec` entries describing each state manager's alias, class path, exclusions, and prompt metadata. Three sync paths keep the catalog current, each hash-guarded so unchanged rows are never rewritten: 1. **Platform builtins** — `seed_builtin_primitives()` in [builtins\_catalog.py](https://github.com/unifyai/unify/blob/main/unify/function_manager/builtins_catalog.py) introspects `ToolSurfaceRegistry.collect_primitives()` and seeds the public builtins project, with per-manager hashes from `stable_hash_for_rows(...)` and stable int IDs derived from `(class_name, method_name)`. 2. **Provider integration tools** — `FunctionManager.sync_provider_integration_tools()` materializes a connected app's tools into the assistant's `Functions/Primitives` with per-app hashes. 3. **Custom source functions** — the `@custom_function` decorator ([custom/\_\_init\_\_.py](https://github.com/unifyai/unify/blob/main/unify/function_manager/custom/__init__.py)) plus `FunctionManager.sync_custom()`, which collects decorated functions and venv manifests from the `custom/` tree and overwrites same-named user rows. ## Writing functions: `add_functions` The learned-skill write path, used by both the doing agent and the [storage pass](/learning/developers/storage-check): ```python theme={null} add_functions( implementations=..., # source with exactly one top-level def language="python", overwrite=False, # True = in-place update, preserves function_id venv_id=None, # required when third-party imports are detected destination=None, # "personal" (default) or "team:" ) ``` The Python path AST-parses the source (`_parse_implementation`), builds `depends_on` via dependency analysis ([dependency\_analysis.py](https://github.com/unifyai/unify/blob/main/unify/function_manager/dependency_analysis.py)), rejects third-party imports without a `venv_id`, executes the source in a sandbox to extract the signature and docstring, and writes batched rows with `recompute_derived=True` so embeddings refresh. Shell functions carry their metadata as `# @name:` / `# @args:` / `# @description:` comments. Deletion is dependency-aware: `delete_function(function_id, delete_dependents=True)` walks the `depends_on` graph breadth-first and removes every compositional function that transitively calls the target. Primitives can't be deleted — they're system-owned. ## Reading: `search_functions` vs `filter_functions` * **`search_functions(query, n=5)`** — semantic, via `federated_ranked_search` against `embedding_text`, spanning personal + team compositional contexts plus the primitive specs (builtins project + provider-backed rows). Results are compacted for the LLM (`_compact_function_search_rows`), and with `_return_callable=True` the matching callables are injected into the sandbox. * **`filter_functions(filter=...)`** — exact, a boolean Python expression over `Function` fields evaluated through `federated_filter`, composed with any instance-level `filter_scope` and `exclude_compositional_ids`. ## Executing: `execute_function` The preferred single-call execution path (the actor's `execute_function` tool synthesizes a call and delegates here). Routing inside `FunctionManager.execute_function`: 1. Resolve the name — compositional row, then primitive registry, then stored primitive rows. 2. `is_primitive=True` → `_execute_primitive` → `get_primitive_callable()`; returns raw results, which may themselves be `SteerableToolHandle`s (steering composes through stored calls). 3. Python compositional → `_execute_python_function`: in-process, inside a pooled venv (`VenvPool`, keyed by `(venv_id, session_id)` with `state_mode` of `stateless`/`stateful`/`read_only`), or on the Windows VM when `windows_os_required`. 4. Shell → `_execute_shell_function` via the `ShellPool`. Venvs materialize on disk under `{local_root}/.unity/venvs/{context}/{venv_id}/` and are managed through `add_venv` / `set_function_venv` / `delete_venv` (FK `SET NULL` on delete, so functions survive a deleted venv). ## Actor-facing tool names Registered in [code\_act\_actor.py](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py) with the class-name prefix: `FunctionManager_search_functions`, `FunctionManager_filter_functions`, `FunctionManager_list_functions`, `FunctionManager_add_functions`, `FunctionManager_delete_function`, and the venv suite (`FunctionManager_add_venv`, `FunctionManager_set_function_venv`, …). The read wrappers are what the [discovery-first gate](/learning/developers/architecture#the-discovery-first-gate) requires before the rest of the surface unlocks. # GuidanceManager Source: https://docs.unify.ai/learning/developers/guidance-manager The procedural library: playbooks, previews, images, and builtins [`GuidanceManager`](https://github.com/unifyai/unify/blob/main/unify/guidance_manager/guidance_manager.py) stores procedural how-to text — in the words of its contract in [base.py](https://github.com/unifyai/unify/blob/main/unify/guidance_manager/base.py): > Stores procedural how-to information: step-by-step instructions, standard > operating procedures, software usage walkthroughs, and strategies for > composing functions together. It's deliberately minimal: pure CRUD plus federated search, orchestrated by the `CodeActActor` (the older ask/update LLM loops were removed — [prompt\_builders.py](https://github.com/unifyai/unify/blob/main/unify/guidance_manager/prompt_builders.py) is now a stub, with all prompt content living in the actor). ## The `Guidance` model Defined in [types/guidance.py](https://github.com/unifyai/unify/blob/main/unify/guidance_manager/types/guidance.py): | Field | Role | | -------------- | --------------------------------------------------------------------------------------------------------- | | `guidance_id` | Auto-increment for tenant rows; stable hash IDs for builtins; `UNASSIGNED = -1` sentinel pre-persist | | `title` | Short title (1–200 chars) | | `content` | The full procedure text | | `images` | `AnnotatedImageRefs` — annotated screenshots, resolved through the `ImageManager` (`_resolve_image_refs`) | | `function_ids` | Links to `Functions/Compositional` rows (FK, cascade on delete) | | `is_builtin` | Marks read-only platform catalogue entries | Rows inherit `AuthoredRow`, so team-shared entries carry `authoring_assistant_id` — which assistant wrote a shared playbook is always attributable. ## CRUD semantics | Method | Behavior | | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `search(references, k=10)` | `federated_ranked_search` across personal + team contexts + the builtins spec; returns **previews** | | `filter(filter=...)` | `federated_filter` boolean expressions; previews again | | `get_guidance(guidance_id)` | Federated fetch by id — the **full untruncated content** | | `add_guidance(title, content, images, function_ids, destination)` | Requires at least one of title/content/images; returns a `ToolOutcome` | | `update_guidance(...)` | Partial update; `_raise_if_builtin` blocks builtins | | `delete_guidance(guidance_id, destination)` | Same builtin guard | The preview mechanic matters for correctness: search and filter truncate `content` at `GUIDANCE_PREVIEW_CHARS = 2000` (via `_with_content_preview`), appending a hint to call `get_guidance` for the full text. The actor prompt (`_FUNCTION_AND_GUIDANCE_LIBRARY`) instructs the model to fetch the full entry before following a long procedure — acting on a truncated preview is the classic failure mode this design prevents. Writes take the standard `destination` parameter (`"personal"` default, `"team:"` for shared SOPs), routed through `ContextRegistry.write_root` with membership validation — see [scopes](/learning/developers/architecture#scopes-personal-team-builtins). ## Pinning: `prompt_guidance` Sub-actors can have guidance **pinned into their system prompt** rather than discovered. `_ActorRunner.act(..., prompt_guidance=[...])` in [environments/actor.py](https://github.com/unifyai/unify/blob/main/unify/actor/environments/actor.py): 1. `_resolve_prompt_guidance` matches strings against `title` and ints against `guidance_id`. 2. Each match is re-fetched via `get_guidance` — full content, not a preview — and merged into the inner actor's guidelines. 3. The resolved ids are set as `exclude_ids` on the inner `GuidanceManager`, so the pinned entries don't reappear as duplicate discovery hits. ## The builtins catalogue [builtins\_catalog.py](https://github.com/unifyai/unify/blob/main/unify/guidance_manager/builtins_catalog.py) seeds a platform-wide, read-only set of guidance entries from the committed snapshot `builtins_guidance.json` (skills imported from the open Agent Skills ecosystem — spreadsheet handling, document work, and so on): * IDs come from `stable_guidance_id(title)`; content changes are detected per-skill via `entry_hash(title, content)` against the `guidance_hash_by_skill` map in `Guidance/Meta`, so reseeding is a diff, not a rewrite. * Tenants read builtins through the federated search's builtins spec (`_builtins_read_spec()`) but can never mutate them — the intended pattern is copy-and-customize into a personal or team entry. * Builtins embed both a truncated `_content_head` (first 24,000 chars, `CONTENT_EMBED_HEAD_CHARS`) into `_content_emb` and the `title` into `_title_emb`; tenant entries embed `content` only, warmed by `warm_embeddings()`. ## Actor-facing tool names `GuidanceManager_search`, `GuidanceManager_filter`, `GuidanceManager_get_guidance`, `GuidanceManager_add_guidance`, `GuidanceManager_update_guidance`, `GuidanceManager_delete_guidance` — all registered in [code\_act\_actor.py](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py), available in both the doing loop (for explicit user-requested writes) and the [storage loops](/learning/developers/storage-check) (for learned writes). # Knowledge & Memory Source: https://docs.unify.ai/learning/developers/knowledge-and-memory KnowledgeManager's NL tool loops and MemoryManager's background consolidation Facts flow into the assistant along two pipelines: a **live, actor-driven** path through [`KnowledgeManager`](https://github.com/unifyai/unify/blob/main/unify/knowledge_manager/knowledge_manager.py), and a **background consolidation** pass in [`MemoryManager`](https://github.com/unifyai/unify/blob/main/unify/memory_manager/memory_manager.py) that distills recent traffic into durable state. Knowledge acquisition pipelines: the live path runs CodeActActor through primitives.knowledge into an inner LLM tool loop over Knowledge tables; the background path runs EventBus events through MemoryManager.process_chunk into ContactManager, KnowledgeManager, and TaskScheduler Knowledge acquisition pipelines: the live path runs CodeActActor through primitives.knowledge into an inner LLM tool loop over Knowledge tables; the background path runs EventBus events through MemoryManager.process_chunk into ContactManager, KnowledgeManager, and TaskScheduler ## KnowledgeManager ### Three steerable operations The contract ([base.py](https://github.com/unifyai/unify/blob/main/unify/knowledge_manager/base.py)) exposes exactly three natural-language operations, each returning a `SteerableToolHandle`: * **`ask`** — read-only interrogation. *"This call must never create, modify or delete knowledge or schema."* * **`update`** — mutations described as desired end-state: *"Do not request how the change should be implemented; describe the desired end-state in natural language."* * **`refactor`** — schema restructuring across knowledge tables. Each spins up its own inner LLM tool loop (`start_async_tool_loop`) with a prompt from [prompt\_builders.py](https://github.com/unifyai/unify/blob/main/unify/knowledge_manager/prompt_builders.py) (`build_ask_prompt` / `build_update_prompt` / `build_refactor_prompt`) and a registered tool group: the ask loop gets `_tables_overview`, `_filter`, `_search`, `_reduce` (plus join variants for multi-table stores); the update loop adds `_add_rows` and the full schema suite (`_create_table`, `_transform_column`, `_vectorize_column`, `_delete_rows`, …). ### Storage is dynamic tables, not documents Knowledge is **not** a fixed title/content/tags store. It's a dynamic multi-table layout under the `Knowledge/` context root — personal at `{base}/Knowledge/{table}`, team-shared at `Teams/{team_id}/Knowledge/{table}` (the `destination` parameter, validated by `ContextRegistry.write_root`). Tables have typed snake\_case columns (`ColumnType` in [types.py](https://github.com/unifyai/unify/blob/main/unify/knowledge_manager/types.py)) and an auto-increment unique key; `_add_rows` auto-creates missing columns with inferred types. Under the hood, KnowledgeManager is a thin domain layer over [`DataManager`](https://github.com/unifyai/unify/blob/main/unify/data_manager/base.py) — every filter, search, join, and reduction delegates to it (see [storage.py](https://github.com/unifyai/unify/blob/main/unify/knowledge_manager/storage.py), [search.py](https://github.com/unifyai/unify/blob/main/unify/knowledge_manager/search.py), and [ops.py](https://github.com/unifyai/unify/blob/main/unify/knowledge_manager/ops.py)). Semantic search runs through the same [federated machinery](/learning/developers/architecture#scopes-personal-team-builtins) as the skill libraries, with multi-reference queries ranked by summed cosine distance and embedding columns created on demand (`_vectorize_column` → `ensure_vector_column`). The ask prompt is emphatic about routing: *"For ANY semantic question over free-form text, ALWAYS use search"* — never substring filters. ### How it's reached at answer time There's no ConversationManager fast-path for knowledge. A question like "what's our refund policy?" routes CM → `act` → the actor calling `execute_function` on **`primitives.knowledge.ask`** (wired through the [primitives registry](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/registry.py)). Document ingestion follows the same actor-orchestrated pattern: parse with `primitives.files.parse`, persist extracted facts with `primitives.knowledge.update`. ## MemoryManager ### The 50-event window [`MemoryManager`](https://github.com/unifyai/unify/blob/main/unify/memory_manager/memory_manager.py) is the offline consolidator — per its contract, *"invoked every 50 messages (by default)"* (`_CHUNK_SIZE = 50`). It subscribes to the EventBus (`Message` events plus ConversationManager `ManagerMethod` events), buffers them in an in-memory window (`_recent_messages`), and on overflow runs `process_chunk` over the rendered transcript under a chunk lock. It doesn't query the transcript store — it rides the same event stream the `TranscriptManager` publishes while persisting. ### Extraction categories `process_chunk` runs one unified pass (`build_unified_prompt` in [prompt\_builders.py](https://github.com/unifyai/unify/blob/main/unify/memory_manager/prompt_builders.py)), with each category gated by a `MemoryConfig` flag (env-configurable via `UNITY_MEMORY_*` in [settings.py](https://github.com/unifyai/unify/blob/main/unify/memory_manager/settings.py)): | Category | Default | Lands in | | ----------------- | ------- | ------------------------- | | Contact CRUD | off | `ContactManager` rows | | Bios | **on** | `Contact.bio` | | Rolling summaries | **on** | `Contact.rolling_summary` | | Response policies | **on** | `Contact.response_policy` | | Domain knowledge | off | `KnowledgeManager.update` | | Task commitments | off | `TaskScheduler.update` | The defaults explain observed behavior: contact character (bios, summaries, per-contact communication policies) accrues automatically from conversation, while knowledge and task extraction are opt-in — those flow mainly through the explicit actor paths. Two prompt disciplines are worth copying if you extend this: a hard anti-hallucination rule (*"Include only information explicitly stated in the provided transcript chunk"*) and an idempotency shield — if the chunk already contains a `manager_method` event showing the operation happened, treat it as handled. There is no persistent watermark; idempotency is in-process (buffer reset + chunk lock) plus prompt-level. ### Extension points * **New extraction category** — add a `MemoryConfig` flag, gate a prompt section in `build_unified_prompt`, expose the env var, and wire it in the CM's manager setup. The standalone methods (`update_contacts`, `update_knowledge`, `update_tasks`) show the per-category pattern. * **Custom knowledge schemas** — the update/refactor loops already create tables and columns on demand; `case_specific_instructions` on `ask`/`update` injects per-call system guidance without forking prompts. * **Simulated twins** — `SimulatedKnowledgeManager` and `SimulatedMemoryManager` mirror the contracts for tests, with sandboxes under `sandboxes/knowledge_manager/` and `sandboxes/memory_manager/`. ## Where the boundaries sit A routing summary that keeps the four stores straight in code, mirroring the [user-facing distinction](/learning/knowledge#knowledge-guidance-or-functions): | Content | Store | Write path | | --------------------- | ------------------------- | ---------------------------------------------------- | | Procedural how-to | `Guidance` | `GuidanceManager_add_guidance` (actor/storage loops) | | Executable workflow | `Functions/Compositional` | `FunctionManager_add_functions` | | Facts, reference data | `Knowledge/*` tables | `primitives.knowledge.update` | | People & preferences | `Contacts` | `ContactManager` (largely via MemoryManager) | # StorageCheck Source: https://docs.unify.ai/learning/developers/storage-check The post-run learning pass, proactive storage, and entrypoint certification Learning happens *after* doing. When a [`CodeActActor.act()`](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py) run completes with storage enabled, a second, independent LLM loop — the **skill librarian** — reviews the full trajectory and decides what persists. This page covers that pass, its mid-run sibling `store_skills`, and the certification gate that turns stored functions into recurring-task executors. StorageCheck flow: the trajectory snapshot feeds a skill librarian LLM loop that can add functions, add guidance, certify a task entrypoint, or store nothing; a mid-run store_skills loop feeds summaries into it StorageCheck flow: the trajectory snapshot feeds a skill librarian LLM loop that can add functions, add guidance, certify a task entrypoint, or store nothing; a mid-run store_skills loop feeds summaries into it ## `_StorageCheckHandle`: the two-phase wrapper There is no class named `StorageCheck` — the name appears as the manager event label and loop id. The mechanism is `_StorageCheckHandle`, which wraps the doing loop's handle whenever `effective_can_store` is true or a `PostRunReviewContext` is present: * **`result()`** resolves as soon as **Phase 1** (the doing loop) completes — callers never wait on storage. * `_run_lifecycle` then snapshots the trajectory (`make_messages_safe_for_context_dump`) and calls `_start_storage_check_loop`, publishing progress via `publish_manager_method_event(..., "StorageCheck", ...)` with the display label `_DEFAULT_STORAGE_REVIEW_LABEL = "Storing reusable skills"` — which is exactly what users see in the Console's [Actions pane](/learning/watching-it-work). * **`done()`** is true only once storage finishes. If either library is missing, `_start_storage_check_loop` returns `None` and storage is skipped silently. If the doing loop *failed*, storage still runs (failures can teach pitfalls) but any task-entrypoint review is cleared — certification only considers successful runs. ## The librarian loop `_start_storage_check_loop` builds a fresh LLM client (loop id `StorageCheck(CodeActActor.act)`) — it does not reuse the doing agent's context beyond the injected trajectory. Its system prompt opens: > You are a skill librarian. A CodeActActor has just completed a task. Your > job is to review the execution trajectory and decide whether anything is > worth persisting for future reuse. **Often nothing is — that is perfectly > fine.** The prompt is assembled from shared sections in [code\_act\_actor.py](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py): * **`_STORAGE_WHAT_CAN_BE_STORED`** — code that executed successfully in `execute_code` is storable; configuration should be baked in; fuzzy semantic steps stay as `query_llm(...)` calls inside the stored function; third-party imports need a `venv_id`. * **`_STORAGE_TWO_STORES`** — FunctionManager holds the *what* (building blocks), GuidanceManager the *how* (recipes/playbooks); store the function first, then guidance referencing it via `function_ids`. * **`_STORAGE_BASE_INSTRUCTIONS`** — the four-step method: review → **search existing stores** (dedup) → act → delete superseded entries. A clean, non-redundant library beats a large one. Its tool surface (`_build_storage_tools`) is full CRUD on both libraries plus venv tools, and — when the doing loop's own tools are still winding down — coordination tools (`ask_about_completed_tool`, `stop_inner_storage`, `interject_inner_storage`, …). ## `store_skills`: proactive mid-run storage The doing agent can also store without waiting for the end. The `store_skills(request)` tool: 1. Snapshots the trajectory *so far* from the current `AgentContext` handle. 2. Spawns a separate loop (`ProactiveStorage(CodeActActor.act)`, display label "Proactive skill storage") sharing the same storage tool surface and prompt sections. 3. Returns its handle for async adoption — the doing loop **keeps working** while storage happens alongside. 4. Appends its summary to `AgentContext.proactive_storage_summaries`, which Phase 2 reads so the final pass doesn't duplicate what was already stored. This is what powers "remember this" mid-action: the `ConversationManager` relays the request into the running action via `interject_*`, and the agent calls `store_skills` without breaking stride. The CM is also explicitly prompted *not* to narrate storage events to the user unless asked. ## Task entrypoint certification Recurring [tasks](/tasks/overview) can graduate from LLM-planned runs to a **stored symbolic executor**. The plumbing spans both repos' worth of context but lives in [task\_scheduler.py](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/task_scheduler.py) and the storage loop: 1. When a task run starts, [`ActiveTask.start`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/active_task.py) sets a `PostRunReviewContext` (extension key `task_entrypoint_review`) — but only when the task is recurring/triggered and has no `entrypoint` yet (`TaskScheduler._build_task_entrypoint_review`). 2. That context switches the storage pass into review mode (label "Storing reusable workflow") and adds two tools: * **`attach_entrypoint_to_recurring_task(function_id, rationale, ...)`** — records a stored function as the entrypoint on *future* task instances (`_attach_entrypoint_to_future_instances`). This alone does not change delivery mode. * **`submit_offline_certification_evidence(function_id, certification_evidence, ...)`** — the promotion gate (`_promote_symbolic_candidate_to_offline`). Evidence-only: the librarian must document risk classification, input and equivalence contracts, and managed-primitive usage; it does **not** execute the function. Resubmission is capped (`MAX_OFFLINE_CERTIFICATION_REVISION_ATTEMPTS = 2`). 3. Once certified, future runs call `CodeActActor.act(entrypoint=...)` — the stored function executes directly with **no LLM doing loop**, with `_repair_symbolic_entrypoint` as the fallback when the symbolic path breaks. This is the code path behind the user-facing promise that recurring work "locks in the proven approach": run one is planned, runs N are certified replay. ## Storage permissions Which parts of this machinery are live is controlled per-call in `act()`: | Flag | Effect | | ------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `can_store=False` | Strips `store_skills` and the FM write tools from the doing loop; Phase 2 still runs if a `PostRunReviewContext` demands it | | `can_compose=False` | Strips `execute_code`, package installs, and store-only tools | | Sub-agents | `primitives.actor.act` spawns default to `can_store=False` — nested agents do, they don't learn | # Guidance & Functions Source: https://docs.unify.ai/learning/guidance-and-functions Browse the playbooks and skills your assistant has picked up Everything your assistant learns is inspectable. In the **Brain** section of your assistant's page you'll find **Guidance** (the playbooks) and **Functions** (the skills), alongside Knowledge, Contacts, and Transcripts. Each assistant has its own — select the assistant on the left, then open the pane. ## Guidance: the playbooks The Guidance pane is a document library — *"playbooks that shape how your teammate behaves,"* as its own help puts it. Entries are listed on the left, grouped by date; select one and the full playbook renders as a readable document on the right (drag the divider if either side needs more room). Each entry shows its title, tags, and a **scope badge** — personal, or the [team](/teams/shared-context) it's shared with — and the search box and scope/tag filters keep large libraries navigable. A **Copy** button grabs any playbook's text for use elsewhere. What you'll find there: * **Rules you've taught** — "Exclude internal transfer rows and net refunds against spend when computing monthly spend." * **Procedures you've walked through** — the quarterly-report steps you explained on a call, including the tricky part about using the exchange rate from the first business day of the quarter. * **Pitfalls it learned the hard way** — when an obvious approach failed and a correction fixed it, the corrected approach gets written down. * **Built-in playbooks** — a set of platform-provided how-tos (marked `built-in · read-only`) covering common ground like working with spreadsheets and documents. Your assistant can't change these, but it can create its own tailored variant for you. ## Functions: the skills The Functions pane is a catalog of everything your assistant can *execute* — *"learned Python skills and platform primitives — signatures, docstrings, and source in one place."* It's a searchable card grid, with each card showing the function's name, a one-line signature, and a short description. Filter by kind: * **Learned** — skills your assistant built while working for you: the monthly-spend pipeline, the report generator, the data cleanup routine. These are the compounding payoff of working together. * **Primitives** — the platform's built-in capabilities (sending messages, searching contacts, and so on). These are the raw materials; they're listed so you can see the full toolbox. Opening one notes that its implementation lives in the platform itself rather than as stored code. Click any card for the detail drawer: the **signature**, a plain-English **summary**, a **parameters** table with types and descriptions, what it **returns**, what it **depends on**, and — if you're curious — the actual **implementation**, with a **Copy** button when you want the source elsewhere. A **verified** badge means the skill checks its own results when it runs, and a **linked playbooks** count shows how many guidance entries put it to use. ## They work as a pair Playbooks and skills reference each other — a guidance entry shows how many **linked functions** it uses, and a function shows its **linked playbooks**. That's the shape of real learning here: when your assistant learns a workflow, it typically saves the *skill* (the reusable steps as code) and the *playbook* (when and how to apply it, and what to watch out for) together. ## Reviewing and changing what's learned The panes are for browsing; changes happen in conversation, like everything else: * **Ask what it learned** — "what did you take away from the expenses work?" or "show me the playbooks you have about invoicing." * **Correct an entry** — "that rule about refunds is wrong — refunds should only be netted within the same month." It updates the guidance. * **Remove something** — "forget the workflow for the old CRM, we've migrated." It deletes the stale entries. ## Personal vs. shared By default, learning is **personal** — private to the assistant that learned it. Team-wide material — a shared style guide, an agreed SOP, rules every assistant should follow — can be stored in a [team's shared pool](/teams/shared-context), where every current member benefits. If something should apply to the whole team rather than one assistant, just say so when you teach it. # Knowledge Source: https://docs.unify.ai/learning/knowledge The durable facts and rules your assistant relies on Alongside the playbooks and skills sits the third library: **Knowledge** — in the Console's words, *"durable facts and rules your teammate relies on."* Where [Guidance](/learning/guidance-and-functions) captures *how* to do things, Knowledge captures *what is true*: the reference material your assistant consults so its answers rest on your reality, not general world-knowledge. ## What lives here * **Facts about your business** — products and pricing, org structure, who's responsible for what, key dates and numbers. * **Policies and rules** — the refund policy, the escalation thresholds, compliance constraints the assistant must respect. * **Digested documents** — the durable takeaways from material you've [shared](/learning/teaching#hand-it-the-reading): process docs, specs, contracts, style guides. * **Reference data it gathered itself** — facts it looked up and confirmed while working, kept so it doesn't have to re-research them. ## How it fills up Like the other libraries, Knowledge grows through natural interaction rather than data entry: * **Send it the reading.** Documents, links, and files you'd give a new hire get digested — the durable facts land here. * **Tell it things.** "Our enterprise tier starts at 50 seats" or "Priya owns vendor relationships now" becomes a knowledge entry it will use from then on. * **It learns on the job.** Facts established during work — confirmed details, resolved ambiguities — get written down so the next job starts from what's known. When your assistant then answers "what's our refund window?" or drafts a reply that cites the right policy, this library is where that certainty comes from — searched at working time alongside guidance and skills. ## The Knowledge pane In the Console's Brain section, **Knowledge** uses the same split document library as Guidance: entries listed on the left (title, tags, scope badge, dates), the full entry rendered as a readable document on the right, with a divider you can drag. Search, filter by **scope or tag**, and use **Copy** to take an entry's text elsewhere. The scope badge matters here just as it does for playbooks: an entry is either **personal** to this assistant or shared with a [team](/teams/shared-context) — which is how a whole unit keeps its reference facts in sync instead of each assistant maintaining its own version of the truth. ## Keeping it accurate Knowledge is only as useful as it is current, and maintenance is conversational: * **Ask what it knows** — "what do you know about our pricing?" and it answers from the library, so you can spot gaps or stale entries. * **Correct it** — "the enterprise tier moved to 40 seats last month" updates the entry. * **Retire it** — "forget the old travel policy, it's been replaced" removes what no longer holds. ## Knowledge, Guidance, or Functions? The three libraries divide cleanly, and knowing which is which makes your teaching land in the right place: | Library | Holds | Example | | ------------- | ----------------- | ------------------------------------------------------------ | | **Knowledge** | What is *true* | "Invoices over £10k need director sign-off" | | **Guidance** | How to *act* | "When processing invoices, check the PO number first, then…" | | **Functions** | What it can *run* | The invoice-validation workflow itself, as reusable code | One fact, one playbook, one skill — often born from the same piece of work, each filed where it belongs. # Overview Source: https://docs.unify.ai/learning/overview Correct your assistant once, and the fix sticks The difference between a tool and a teammate is that a teammate learns. When you correct your assistant, show it how you like something done, or simply work through a problem together, that experience doesn't evaporate when the chat ends — it becomes part of how your assistant works from then on. You shouldn't have to repeat yourself on similar work. ## How learning works There's no training mode and no settings page. Learning happens as a natural by-product of working together: Ask for things, correct what comes back, explain your preferences — exactly as you would with a new colleague. After it finishes a piece of work, your assistant looks back over what happened and asks itself: *is anything here worth keeping for next time?* A rule you corrected, a workflow that succeeded, a pitfall it hit — those get saved. Routine work saves nothing, and that's by design: a small, sharp library beats a big noisy one. Before starting any new piece of work, your assistant first checks what it has learned — the relevant playbooks and skills — and applies them, rather than reinventing the approach from scratch. ## What it learns: playbooks and skills Learned material lands in two libraries, both visible in the Console: *How* you want things done: rules, preferences, step-by-step procedures, and pitfalls to avoid. "Exclude internal transfers when computing monthly spend" lives here. *What* your assistant can do again: concrete, reusable workflows it built while working for you. The corrected monthly-spend pipeline itself lives here. The two work together — a playbook often references the skills it uses. Think of a function as a tool in the toolbox, and guidance as the recipe that says which tools to use, in what order, and what to watch out for. A third library, [**Knowledge**](/learning/knowledge), holds *facts* — documents you've shared, things that are true about your business. Guidance is *how-to*; Knowledge is *what-is*. Your assistant also quietly keeps notes on the people you both deal with — their details and preferences — in **Contacts**. ## Seeing it happen Learning isn't a black box. The Console shows you all of it: * The [**Actions** pane](/learning/watching-it-work) shows your assistant's work live, step by step — including the moment it checks its libraries ("Searching for relevant guidance") and the moment it saves something new ("Storing reusable skills"). * The [**Guidance** and **Functions** panes](/learning/guidance-and-functions) let you browse everything it has learned, as readable documents and inspectable skills. ## What's in this section The Actions pane — every request, every step, live. Browsing the playbooks and skills your assistant has picked up. The durable facts and rules its answers rest on. Corrections, walkthroughs, documents — and how to make them stick. Building on or extending the open-source runtime? The [Developers](/learning/developers/architecture) sub-section documents how all of this is implemented in [`unifyai/unify`](https://github.com/unifyai/unify) — the dual library, the two-phase actor loop, StorageCheck, and the knowledge and memory subsystems. # Teaching your assistant Source: https://docs.unify.ai/learning/teaching Corrections, walkthroughs, and documents — and how to make them stick You don't need a special mode to teach your assistant — every natural way of showing a colleague how you work, works here too. What matters is knowing which ways are most effective. ## Correct the work, not just the words The single highest-leverage teaching move is a **specific correction while work is happening**. When your assistant's first attempt misses the mark, don't just fix the output yourself — tell it what was wrong: > "That total is off — exclude internal transfer rows and net refunds > against spend when computing monthly spend." Your assistant revises the work, and when the job wraps up, the review pass turns your correction into durable material: a playbook rule it will apply to every similar job, and often a corrected, reusable skill alongside it. One correction, permanent fix. ## Say "remember this" If your assistant just did something exactly the way you want it done again, say so while it's working: *"remember this"*, *"save this workflow"*. It stores the approach without breaking stride. The same works for pure telling: *"remember: invoices always go to accounts@ — never to the requester directly"* becomes guidance on the spot. ## Show it once For anything easier to show than describe, use a [Unify Meet call with screen sharing](/communication/unify-meet): > "Whenever a customer calls about a billing issue, you always check this > Override section first, then click the Transactions tab to see the full > history." Walk through it once, narrating as you go — your assistant captures the procedure (screenshots included, when useful) as a playbook. This is also the natural setup for [recurring work](/tasks/overview): show it once, then "do this every Friday". ## Hand it the reading Anything you'd give a new hire, you can give your assistant: process docs, style guides, links, past examples. Send them over any channel and it will go away and digest them. Facts land in [**Knowledge**](/learning/knowledge); procedures become **Guidance**. ## Try the guided demo Onboarding includes a hands-on step — **"Teach me by correcting me"** — that walks the whole loop with a deliberately contrived example: your assistant computes a monthly spend total from two bank exports, makes a naive mistake (double-counting an internal transfer), and hands you the exact correction to send. You send it, watch the fix get saved, then ask for the next month — and it's right the first time, using what it learned. Five minutes, and you'll have seen every stage of learning with your own eyes. ## Habits that compound * **Correct early.** The first time something's off is the cheapest time to fix it forever. Letting a wrong pattern repeat teaches the wrong pattern. * **Give the reason, not just the fix.** "Use the exchange rate from the first business day of the quarter — finance reconciles against that" makes for a much better playbook than "wrong number, redo it." * **Watch the first run.** When a newly-taught workflow or new [task](/tasks/overview) runs for the first time, keep an eye on the [Actions pane](/learning/watching-it-work) — a small correction on run one beats a cleanup on run ten. * **Audit occasionally.** Skim [Guidance](/learning/guidance-and-functions) every so often, like reviewing a colleague's notes. If something reads wrong or stale, say so and it gets fixed or removed. * **Teach the team, not just one assistant.** If a rule applies to everyone, say so — it can live in a [team's shared pool](/teams/shared-context), where every assistant on the team follows it. # Watching it work Source: https://docs.unify.ai/learning/watching-it-work The Actions pane — every request, every step, live The **Actions** pane on your assistant's page is the window into its thinking: every request it has worked on in a time window, expandable down to the individual steps it took. If you've ever wondered *"what is it actually doing right now?"* — this is the answer. ## The feed Actions are listed oldest to newest, one card per request. Each card shows what was asked and how it went, with a status pill: | Status | Meaning | | --------------------- | -------------------------------- | | **Running** | Working on it right now | | **Waiting for input** | Paused, needs something from you | | **Done** | Finished | | **Failed** | Something went wrong | The footer keeps a running pulse: whether your assistant is **working** or **idle**, how many actions are running or waiting for input, and a **Live** indicator confirming you're seeing events as they happen, with an "Updated" timestamp alongside. ### Finding what you're after * **The clock control** scopes the feed to a time window — presets run from the last 30 minutes up to the last 7 days, plus Today and Yesterday. Scroll up past the top of the window and earlier history loads in. * **Search** filters the whole timeline by text — jump straight to a specific request, or to any step inside one. * **Expand All / Collapse All** opens or folds every card at once — handy when scanning a day's work versus dissecting one job. ## Inside a request Expand a card and the whole story unfolds: * **The request** — what you (or a schedule, or a trigger) asked for. * **The step timeline** — each stage of the work, in plain labels: "Checking Contact Book", "Searching for relevant guidance", steps for code it ran, pages it read, messages it sent. Busy stretches fold into collapsible sections ("12 steps · 40s") that expand when you want the fine grain — down to individual thoughts, tool calls, and results. * **The final response** — the answer or result the work produced, called out at the bottom of the card. You don't need to understand every step — the point is that you *can* look. Nothing your assistant does is hidden from you. ### What to use it for * **Trust-building** — watch a few requests end-to-end early on, and the assistant stops being a black box. * **Verification** — when a result looks off, the timeline shows exactly where things went sideways: which page it read, what it computed, what it sent. * **Live oversight** — during a [task run](/tasks/overview) or a job you kicked off in chat, this is the play-by-play, streaming as it happens. * **Better corrections** — a correction that names the actual misstep ("you used the current exchange rate at step three — use the quarter-open rate") teaches far more precisely than "the number's wrong." ## Spotting the learning moments Two step labels in the timeline are the learning system at work: * **"Searching for relevant skills" / "Searching for relevant guidance"** — at the very start of a piece of work, your assistant checks its [libraries](/learning/guidance-and-functions) first. This is your past teaching paying off. * **"Storing reusable skills"** — after the work finishes, a quiet review pass decides whether anything from this job is worth keeping. When you see this step, your assistant may be writing a new playbook or skill. Your assistant doesn't narrate this housekeeping in chat — it just happens. But if you're curious what came out of it, ask ("what did you learn from that?") or check the Guidance and Functions panes directly. The Actions pane is also the best seat in the house when a [scheduled task](/tasks/overview) fires or you hit **Test it** on a trigger — you watch the whole run unfold live. # Custom overrides Source: https://docs.unify.ai/local-deployment/custom-overrides Seed and manage state-manager content from version-controlled files Every state manager in the runtime can be seeded from **source files** — plain JSONL and JSON files (plus decorated Python for functions) that you keep in version control. At sync time the runtime reconciles each manager's backing store against these files: entries you declare are inserted, entries you edit are updated, and entries you delete are removed. Content the assistant created on its own is left alone. This turns a local deployment into something you can configure like code: check your assistant's starting contacts, guidance, secrets, tasks, and reference data into a repo, and every fresh deployment converges to the same state. ## How syncing works Each manager exposes the same pair of building blocks (in `unify//custom_*.py` in the [`unifyai/unify`](https://github.com/unifyai/unify) repo): * **Collectors** read the source files from one or more directories and return the declared entries. * **`sync_custom()`** on the manager reconciles the store against those entries. Reconciliation is **hash-based and idempotent**: 1. Every source entry carries a stable `key` and gets a content hash. An aggregate hash over all entries is compared against the last synced value — if nothing changed, sync is a no-op, so it's cheap to run on every startup. 2. On a mismatch, entries are reconciled by `key`: new keys are inserted, changed hashes are updated in place, and keys that disappeared from the source are deleted from the store. 3. Rows the assistant or user created through normal conversation are untouched — sync only manages rows it created. The one exception: if a manually-created row collides with a declared `key`, the source definition adopts it. 4. Runtime-owned state survives updates. A recurring task keeps its execution history and status when you edit its definition, and a task that is actively running is skipped and picked up on the next sync. 5. Invalid lines are skipped with a logged warning — one bad row never blocks the rest of the file. Two fields are understood by every entry type: | Field | Default | Meaning | | ------------- | ------------ | ------------------------------------------------------------------------------------------------------------- | | `destination` | `"personal"` | Where the entry lives — `"personal"` for the assistant's own context, `"team:"` for a shared team context | | `auto_sync` | `true` | Set `false` to stop managing this entry from source (it's skipped entirely) | Blank lines and `#`-prefixed comment lines are allowed in every `.jsonl` file. ## The override cascade Collectors accept a **list of directories** and merge them in order — when the same `key` appears in more than one directory, **the later directory wins**. This gives you a layered override model: ```text theme={null} overrides/ org/ # baseline shared by everyone guidance.jsonl contacts.jsonl me/ # your personal layer — overrides org on key collision guidance.jsonl tasks.jsonl ``` Pass `[org, me]` and an entry keyed `crm-runbook` in `me/guidance.jsonl` replaces the org-level definition of the same key. The hosted product uses this same mechanism to cascade org → user → assistant configuration; a local deployment can use as many or as few layers as it wants. ## Source file formats ### Guidance — `guidance.jsonl` One JSON object per line. `function_names` link the guidance to custom functions by name (resolved to ids at sync time): ```json theme={null} {"key": "crm-runbook", "title": "CRM escalation runbook", "content": "When a customer reports...", "function_names": ["create_crm_ticket"]} ``` ### Contacts — `contacts.jsonl` ```json theme={null} {"key": "sarah-chen", "first_name": "Sarah", "surname": "Chen", "email_address": "sarah@example.com", "job_title": "CTO", "should_respond": true, "response_policy": "Always reply same-day."} ``` Other supported fields: `phone_number`, `whatsapp_number`, `discord_id`, `slack_user_id`, `bio`, `timezone`. `key` may be omitted — it defaults to the lowercased `first_name|surname` pair (secrets similarly default to their `name`). ### Secrets — `secrets.jsonl` ```json theme={null} {"key": "crm-api", "name": "CRM_API_KEY", "value": "…", "description": "Read-write CRM access"} ``` Secret values live in the file, so keep secrets source files out of shared version control (or template them in from your secret store at deploy time). ### Tasks — `tasks.jsonl` Declare recurring or event-triggered work. `schedule` and `trigger` are mutually exclusive; runtime status and execution metadata stay owned by the scheduler: ```json theme={null} {"key": "monday-digest", "name": "Weekly GitHub digest", "description": "Digest this week's GitHub notifications.", "schedule": {"start_at": "2026-07-13T09:00:00Z"}, "repeat": [{"frequency": "weekly", "weekdays": ["MO"], "time_of_day": "09:00"}]} {"key": "vip-email-watch", "name": "VIP email watch", "description": "Summarize and flag anything urgent.", "trigger": {"medium": "email", "recurring": true}} ``` `schedule` holds an ISO-8601 `start_at`; `repeat` is a list of RFC-5545-style patterns (`frequency`, `interval`, `weekdays`, `count`, `until`, `time_of_day`); `trigger` names an inbound communication event (`medium` such as `email` or `sms_message`, optional `from_contact_ids`/`omit_contact_ids`, and `recurring` to re-arm after each run). Other supported fields: `deadline`, `priority`, `response_policy`, `entrypoint_function` (run a stored function instead of re-planning), `offline`. ### Blacklist — `blacklist.jsonl` ```json theme={null} {"key": "spam-caller", "medium": "phone", "contact_detail": "+15551234567", "reason": "Repeated spam"} ``` ### Knowledge tables — directory tree Each table is a subdirectory holding `meta.json` (description, column types, and the `seed_key` used as the per-row merge key) plus `rows.jsonl`. The relative path to `meta.json` becomes the table name, so nesting is allowed: ```text theme={null} knowledge/ Companies/ meta.json # {"description": "...", "columns": {...}, "seed_key": "name"} rows.jsonl CRM/OperatingRules/ meta.json rows.jsonl ``` ### Reference data tables — directory tree Same `meta.json` + `rows.jsonl` shape as knowledge, for DataManager-owned reference tables. `meta.json` additionally supports `context` (target context path), `unique_keys`, and `auto_counting`. ### Dashboards — directory tree Tiles and layouts live under `tiles/` and `layouts/` namespaces, each with the `meta.json` + `rows.jsonl` shape. ### Functions and venvs — Python and TOML Custom functions are ordinary Python decorated with `@custom_function`; venvs are `pyproject.toml`-style files whose filename becomes the venv name: ```python theme={null} # functions/acme_workflows.py from unify.function_manager.custom import custom_function @custom_function(venv_name="acme_ml") def score_lead(company: str) -> float: """Score a sales lead using the ACME model.""" ... ``` ```toml theme={null} # venvs/acme_ml.toml [project] dependencies = ["scikit-learn>=1.4"] ``` Files starting with `_` are ignored. See the [FunctionManager README](https://github.com/unifyai/unify/blob/main/unify/function_manager/README.md) for the full decorator reference. ## Running a sync Syncing is **explicit** — nothing watches the files. Collect from your directory layers and call `sync_custom()` on each manager. Sync functions first, since guidance and tasks resolve function names to ids: ```python theme={null} from pathlib import Path from unify.manager_registry import ManagerRegistry from unify.function_manager.custom_functions import ( collect_functions_from_directories, collect_venvs_from_directories, ) from unify.guidance_manager.custom_guidance import collect_guidance_from_directories from unify.task_scheduler.custom_tasks import collect_tasks_from_directories layers = [Path("overrides/org"), Path("overrides/me")] fm = ManagerRegistry.get_function_manager() fm.sync_custom( source_functions=collect_functions_from_directories(layers), source_venvs=collect_venvs_from_directories(layers), ) name_to_id = { name: data["function_id"] for name, data in fm.list_functions().items() if data.get("function_id") is not None } ManagerRegistry.get_guidance_manager().sync_custom( source_guidance=collect_guidance_from_directories(layers), function_name_to_id=name_to_id, ) ManagerRegistry.get_task_scheduler().sync_custom( source_tasks=collect_tasks_from_directories(layers), function_name_to_id=name_to_id, ) ``` Contacts, secrets, blacklist, knowledge, data, and dashboards follow the same collect-then-sync shape with their own collectors (`collect_contacts_from_directories`, `collect_secrets_from_directories`, and so on). Because every sync is hash-guarded, running the whole pass on every startup costs almost nothing when the files haven't changed. In the hosted product this reconcile runs automatically when an assistant starts, cascading org, user, and assistant configuration layers. In a local deployment you decide when it runs — a startup script that calls the snippet above is the usual shape. # Day-to-day usage Source: https://docs.unify.ai/local-deployment/day-to-day The CLI, chatting, and steering work while it's in flight ## Commands ```text theme={null} unify Interactive local chat (alias: unify chat) unify serve Run headless: ConversationManager + gateway unify stop Stop the local runtime unify status Show runtime status unify logs Follow the runtime log unify doctor Gateway/config checks unify setup Re-run the key/credential wizard unify update Update the checkout and re-sync deps ``` ## Steering while work is in-flight When your teammate is mid-task in the local chat REPL, steer it the same way you would in the hosted Console: **send another message**. There are no special slash commands — steering is a first-class part of the runtime, not a REPL feature. **Text:** use `msg` at the `>` prompt: ```text theme={null} > msg Actually, narrow it to ones with Rust bindings. > msg What step are you on? ``` Each `msg` publishes a normal inbound chat message. The ConversationManager wakes the slow brain, which can answer you or redirect in-flight Actor work through its usual action-steering tools. **Voice:** type `meet` to open a LiveKit browser session, then speak through your mic — see [Voice](/local-deployment/voice). **Debugging:** use `trace`, `tree`, or `show_logs` to inspect what's happening while work is in-flight. ## What this feels like ```text theme={null} You ▸ "Find me high-throughput vector DBs under Apache 2." They ▸ (start searching) You ▸ "Actually, narrow it to ones with Rust bindings." They ▸ (adjust the in-flight search — don't restart) You ▸ "Pause that, something urgent." They ▸ (freeze exactly where they are) ... five minutes later ... You ▸ "OK, resume. How's it going?" They ▸ (pick up where they left off, give you a status update) ``` Every operation — at every level of the call stack — returns a live steerable handle, so corrections propagate down into whatever the runtime is currently doing without restarting it. The mechanics are covered in [Developers](/local-deployment/developers). ## Importing skills from other assistants OpenClaw and Hermes Agent both represent skills as `SKILL.md` files (the [agentskills.io](https://agentskills.io) standard). That maps almost one-to-one onto a GuidanceManager entry, so either skill library can be imported off-the-shelf as guidance: ```bash theme={null} # Dry run (the default): print what would be imported, write nothing .venv/bin/python -m scripts.skill_migration.openclaw_to_guidance .venv/bin/python -m scripts.skill_migration.hermes_to_guidance # Import for real (titles are namespaced "[openclaw] …" / "[hermes] …") .venv/bin/python -m scripts.skill_migration.openclaw_to_guidance --execute .venv/bin/python -m scripts.skill_migration.hermes_to_guidance --execute ``` Each script looks for a sibling checkout (`../openclaw`, `../hermes-agent`) by default; pass `--repo-root` to point elsewhere. Re-runs skip titles that already exist; pass `--conflict overwrite` to update them in place instead. # Developers Source: https://docs.unify.ai/local-deployment/developers How the local runtime is put together, and how to hack on it This page is for anyone reading, extending, or debugging the open-source [`unifyai/unify`](https://github.com/unifyai/unify) runtime they just deployed locally. The per-feature developer sub-sections elsewhere in these docs (communication, workspace, tasks, learning, …) go deep on individual subsystems; this is the top-level map. ## How it works A persistent **interaction loop** (`ConversationManager`) stays present across every medium and keeps thinking while work is in flight. When something needs deeper reasoning, it dispatches a **background reasoner** (`Actor`) that writes Python plans over a back office of typed state managers. Every operation returns a live, steerable handle, and those handles nest — a correction you make in chat propagates *down* through the dispatched action into whatever manager call is currently running. ```text theme={null} ConversationManager (interaction loop, event-driven scheduling) │ │ Slow Brain ◄── IPC ──► Fast Brain (real-time voice + video, LiveKit) │ ▼ CodeActActor (generates Python plans, calls primitives.* APIs) │ ▼ State Managers (each runs its own async LLM tool loop) │ ├── ContactManager — people and relationships ├── KnowledgeManager — domain facts, structured knowledge ├── TaskScheduler — durable tasks, schedules, triggers ├── TranscriptManager — conversation history and search ├── GuidanceManager — procedures, SOPs, how-to knowledge ├── FileManager — file parsing and registry ├── ImageManager — image storage, vision queries ├── FunctionManager — user-defined functions, primitives registry ├── WebSearcher — web research orchestration ├── SecretManager — encrypted secret storage ├── BlacklistManager — blocked contact details └── DataManager — low-level data operations │ ├── EventBus — typed pub/sub backbone (Pydantic events) └── MemoryManager — offline consolidation every 50 messages ``` ### Steerable handles — the universal protocol Every public manager method returns one — the same `ask`, `interject`, `pause`, `resume`, `stop` surface at every level of the call stack: ```python theme={null} handle = await actor.act("Survey high-throughput vector DBs and draft a comparison") await handle.interject("Only ones with Rust bindings") # mid-flight redirect await handle.pause(); ...; await handle.resume() # freeze and resume ``` When the Actor calls `primitives.contacts.ask(...)`, the `ContactManager` returns its own handle — nested inside the Actor's, which is nested inside the `ConversationManager`'s. Steering at any level propagates down through the live call stack as a typed signal any inner loop can act on, not as an abort or a queued prompt. ### CodeAct — the Actor writes Python programs Rather than emitting one JSON tool call at a time, the Actor writes a single sandboxed Python program per turn over typed `primitives.*`: ```python theme={null} deps = await primitives.knowledge.ask( "Which Python deps am I tracking for security updates?" ) for dep in deps: latest = await primitives.web.ask( f"What's the latest released version of {dep}?" ) await primitives.knowledge.update( f"Record that {dep}'s latest known release is {latest}." ) ``` A memory lookup → external check → memory write becomes one coherent plan with real variables, loops, and control flow. ## Project structure ```text theme={null} unify/ ├── unify/ # Main package — actor, conversation_manager, common, and one folder per state manager ├── sandboxes/ # Dev / eval playgrounds, one per manager; backs the `unify` CLI ├── tests/ # Pytest suite (cached LLM responses) ├── agent-service/ # Node.js desktop / browser automation └── deploy/ # Dockerfile, Cloud Build, virtual desktop ``` ## Where to start reading | File | What's there | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `unify/common/async_tool_loop.py` | `SteerableToolHandle` — the protocol everything returns | | `unify/common/_async_tool/loop.py` | The async tool loop engine — nesting, steering, context propagation | | `unify/actor/code_act_actor.py` | CodeAct — plan generation, sandbox, primitives | | `unify/conversation_manager/conversation_manager.py` | Dual-brain orchestration, debouncing, in-flight actions | | `unify/conversation_manager/domains/brain_action_tools.py` | How the brain starts, steers, and tracks concurrent work | | `unify/conversation_manager/domains/call_manager.py` | LiveKit subprocess + voice/video event wiring | | `unify/function_manager/primitives/registry.py` | How primitives are assembled into the typed API surface | | `unify/events/event_bus.py` | Typed event backbone | | `unify/memory_manager/memory_manager.py` | Offline consolidation pipeline | | `unify//custom_*.py` | File-source collectors behind [custom overrides](/local-deployment/custom-overrides) | The full breakdown — async tool loop internals, event bus, primitive registry, hosted deployment SPI — lives in the repo's [`ARCHITECTURE.md`](https://github.com/unifyai/unify/blob/main/ARCHITECTURE.md). ## Running the tests Tests exercise the real system — steerable handles, CodeAct, manager composition, nested tool loops — against real LLMs whose responses are cached per unique input, not mocked: ```bash theme={null} uv sync --all-groups source .venv/bin/activate tests/parallel_run.sh tests/ # everything tests/parallel_run.sh tests/actor/ # one module tests/parallel_run.sh tests/contact_manager/ # another ``` First run makes live LLM calls; subsequent runs replay from cache in milliseconds. See the repo's [`tests/README.md`](https://github.com/unifyai/unify/blob/main/tests/README.md) for the full philosophy — delete the cache and you're re-evaluating against live models. # Installation Source: https://docs.unify.ai/local-deployment/installation One command to a locally-running teammate ## Prerequisites * **Python 3.12+** * **macOS, Linux, or WSL2** * An **LLM provider key** — OpenAI, Anthropic, or DeepSeek * A **Unify API key** and an assistant — create both at [console.unify.ai](https://console.unify.ai) ## Install ```bash theme={null} curl -fsSL https://raw.githubusercontent.com/unifyai/unify/staging/scripts/install.sh | bash ``` The installer clones `unify`, syncs Python dependencies with `uv` (which resolves the sibling `unisdk` and `unillm` packages), runs a key wizard, and installs a `unify` CLI shim in `~/.local/bin/`. **Open a new terminal**, then start chatting: ```bash theme={null} unify ``` `unify` opens an interactive local chat with one teammate, powered by the full local runtime and the hosted Orchestra backend. Run headless (ConversationManager + gateway) with `unify serve`. ```text theme={null} > What did I leave half-finished on the indexer rewrite last week? > Watch my open PRs and ping me when one gets reviewed. > Remind me to send Sarah the benchmark numbers on Thursday. ``` Clones `unify` under `~/.unity/`, runs `uv sync --all-groups`, and writes `~/.unity/unity/.env` with your `UNIFY_KEY`, `ASSISTANT_ID`, `ORCHESTRA_URL` (the hosted backend), and an LLM provider key — plus optional voice and research keys via the bring-your-own-keys wizard. It installs a `unify` CLI shim in `~/.local/bin/` with a clearly-marked PATH block appended to your shell rc. Get your `UNIFY_KEY` and `ASSISTANT_ID` from [console.unify.ai](https://console.unify.ai). If you skip a key at install time (or pipe through a non-interactive shell), add it to `~/.unity/unity/.env` and run `unify setup`. ## Configuration All bring-your-own-keys (LLM, voice, research) live in `~/.unity/unity/.env`. Edit the file and run `unify setup` to apply changes. The keys the runtime understands: | Variable | Purpose | Required? | | ----------------------------------------------------------- | -------------------------------------------------- | ------------------------------------ | | `UNIFY_KEY` | Authenticates against the hosted Orchestra backend | Yes | | `ASSISTANT_ID` | Which assistant record the runtime drives | Yes | | `ORCHESTRA_URL` | Backend URL (defaults to the hosted backend) | Set by installer | | `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `DEEPSEEK_API_KEY` | Lets the assistant think and reply | One of these | | `TAVILY_API_KEY` | Web search while researching (free tier) | Optional | | `ANTI_CAPTCHA_KEY` | CAPTCHA solving during computer use | Optional | | LiveKit + speech keys | Browser voice calls | See [Voice](/local-deployment/voice) | ## Running from a checkout If you cloned the repo yourself rather than using the installer: ```bash theme={null} pip install uv && uv sync --all-groups ``` The interactive chat is `.venv/bin/python -m sandboxes.conversation_manager.sandbox`, and the headless runtime is `bash scripts/local.sh start --full`. Always use the project virtualenv at `.venv/` — the key wizard is available as `scripts/prompt_byok_keys.sh`. ## Updating ```bash theme={null} unify update ``` This updates the checkout and re-syncs dependencies. Your `.env` is preserved. # Overview Source: https://docs.unify.ai/local-deployment/overview Run the open-source unify runtime on your own machine Everything your assistant's brain does — the reasoning loop, memory, steerable tasks, voice — lives in the MIT-licensed [`unifyai/unify`](https://github.com/unifyai/unify) repository, and you can run it locally. A local deployment gives you **one teammate** running entirely on your laptop, talking to the hosted Orchestra backend for persistence, so you can trace exactly how it thinks while keeping the zero-maintenance storage layer. The shape is deliberately simple: install once, and the runtime lives on your machine, accumulating state across every session. No Docker, no Kubernetes, no local database. ## What runs where The local runtime drives the assistant record whose `ASSISTANT_ID` you set, provisioned at [console.unify.ai](https://console.unify.ai). Locally you get: * **Chat** — an interactive REPL with the full ConversationManager + Actor runtime behind it. An LLM key (OpenAI, Anthropic, or DeepSeek) is what lets it think and reply. * **Browser voice calls** — the production fast-brain (interruption-handling, telephony-aware) running locally with sub-second latency. See [Voice](/local-deployment/voice). * **Web search** — a free [Tavily](https://tavily.com) key lets it look things up while researching. * **Computer use** — it drives a real browser and desktop; an optional [AntiCaptcha](https://anti-captcha.com) key lets it get past CAPTCHAs instead of stalling. The onboarding flow, inbound messaging channels (SMS / WhatsApp / phone, Slack, Gmail, Outlook, Teams, Discord), Google / Microsoft workspace connect, third-party app integrations, screen-share, and the multi-teammate experience are part of the hosted product at [console.unify.ai](https://console.unify.ai). ## The runtime stack The open one-teammate runtime is three MIT-licensed repos. They talk to the hosted Orchestra backend; you can also use any of them independently. | Repo | Role | | ----------------------------------------------- | ---------------------------------------------------------------------------- | | [**unify**](https://github.com/unifyai/unify) | Agent runtime — managers, tool loops, CodeAct, voice, orchestration | | [**unisdk**](https://github.com/unifyai/unisdk) | Python SDK — how the runtime talks to Orchestra | | [**unillm**](https://github.com/unifyai/unillm) | LLM access layer — OpenAI, Anthropic, or any compatible endpoint | | **orchestra** | Persistence backend — hosted at [console.unify.ai](https://console.unify.ai) | ## Start here One command, a key wizard, and a `unify` CLI in your PATH — a few minutes on macOS, Linux, or WSL2. Real browser voice calls with sub-second latency — LiveKit Cloud plus free-tier speech keys. The CLI commands, chatting, and steering work while it's mid-task. Seed contacts, guidance, tasks, secrets, and reference data from version-controlled files. How the runtime is put together, running the tests, and where to start reading the code. # Voice Source: https://docs.unify.ai/local-deployment/voice Talk to your locally-running teammate in the browser Real voice calls run the production fast-brain — interruption-handling, telephony-aware — locally with sub-second latency. From the local chat REPL, type `meet` to open a LiveKit browser session and speak through your mic. Utterances follow the same Unify Meet path as production voice. ## What you need Voice uses **LiveKit Cloud** for browser Meet sessions, plus one speech-to-text and one text-to-speech provider (both have free tiers; pick **one** TTS provider). The install wizard prompts for these; to add them later, edit `~/.unity/unity/.env` and run `unify setup`. | Variable | Purpose | Where to get it | | ------------------------------------------------------ | ------------------------- | ----------------------------------------------------------------------------------------------------- | | `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | Browser voice media | [cloud.livekit.io](https://cloud.livekit.io) | | `DEEPGRAM_API_KEY` | Speech-to-text | [console.deepgram.com](https://console.deepgram.com) — free tier | | `CARTESIA_API_KEY` *or* `ELEVEN_API_KEY` | Text-to-speech (pick one) | [play.cartesia.ai](https://play.cartesia.ai) or [elevenlabs.io](https://elevenlabs.io) — free credits | ## How local voice works Live calls run two coordinated brains: * **Slow brain** (`ConversationManager`) — sees everything, decides deliberately, runs in the main process. * **Fast brain** — a real-time LiveKit voice agent in a subprocess, sub-second latency, handles turn-taking autonomously. They communicate over IPC. The slow brain steers the fast brain with **SPEAK** (say exactly this), **NOTIFY** (here's context, decide what to do), or **BLOCK** (do nothing; carry on). This is the same dual-brain architecture the hosted product runs — locally you're just running both halves on your own machine. Phone calls over real telephony, video calls, and screen-share are part of the hosted product at [console.unify.ai](https://console.unify.ai). # Creating tasks Source: https://docs.unify.ai/tasks/creating Describe the work once, in plain language Tasks are created by **talking to your assistant** — in Console chat, over text, on a call, wherever. There's no form to fill in. Say what you want done, when (or on what event), and how you'd like to hear back; your assistant sets it up with sensible defaults for anything you didn't specify, asking a short question only when something essential is missing. > **You:** Every Monday morning, pull the open Jira tickets, sort by > priority, and email me the top ten. > > **Assistant:** Done — scheduled for Mondays at 8am. You'll get the email at > your usual address, and the task is listed in the Tasks tab. ## Schedules Say times the way you naturally would — your assistant turns them into a proper schedule: * **One-off** — "tomorrow at 9", "in two hours", "on the 30th". * **Recurring** — "every morning at 8", "every 30 minutes", "Mondays and Wednesdays at noon", "the first of each month". * **With an end** — "every day until the launch", "for the next four weeks". Times use your assistant's timezone unless you say otherwise ("8am UK time"). If a request is genuinely ambiguous, it asks rather than guessing. ## Triggers Triggered tasks fire on **incoming events** — a message or email arriving on any connected channel. You can scope them precisely: * **By channel** — "when I get an email…", "when someone messages me on Slack…", "when a WhatsApp comes in…" * **By sender** — "when *Alice* emails about invoices…", or the reverse: "ignore anything from the newsletter address". * **Once or every time** — a trigger can fire once and retire, or re-arm after each run and keep watching. ### Test it You don't have to wait for a real event to know a trigger works. Armed triggered tasks have a **Test it** control in the Console that fires the task immediately so you can watch it run — and the trigger stays armed for the real thing afterwards. ## Changing and removing tasks Managing tasks works the same way as creating them — just tell your assistant: * *"Move the morning rundown to 7:30."* * *"Skip the weekly recap this Friday."* * *"Cancel the inbox-check task."* The Tasks tab is where you *see* everything; chat is where you *change* everything. ## During onboarding The onboarding checklist walks you through both kinds: **Create a scheduled task** and **Create a triggerable task**. Each row starts a short back-and-forth with your assistant, and the example chips beneath them — like *"In two minutes, check my inbox and text me anything urgent"* — set up that exact task with one click. The scheduled step even shows a countdown until your assistant reaches out, so you see the whole loop close in real time. A great first task mirrors something you already do manually every day or week. If you'd normally check it, chase it, or compile it — describe that to your assistant once, and take it off your plate. # Task system internals Source: https://docs.unify.ai/tasks/developers How the task system actually works — a code-level tour of the open-source runtime Everything in this section so far describes tasks as a user experiences them. This page is for developers: how tasks are modeled, scheduled, activated, executed, and certified inside the open-source [`unifyai/unify`](https://github.com/unifyai/unify) runtime. The heart of it is the [`unify/task_scheduler`](https://github.com/unifyai/unify/tree/main/unify/task_scheduler) package — start with its [README](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/README.md), which this page expands on. Three design commitments shape everything below: 1. **Tasks are rows, not processes.** A task is a durable record in a storage context; execution is something that *happens to* the row, and every lifecycle transition is a field update you can inspect. 2. **Tasks are independent.** There is no queue and no chaining between tasks — each fires on its own clock or trigger, and concurrent runs are normal. 3. **The description is the program** (by default). Unless a task has been promoted to a certified symbolic entrypoint, execution means handing the task's natural-language `description` to the actor. ## The task model Anatomy of a Task: the task row with name/description, priority/deadline, destination, status; schedule XOR trigger with repeat patterns; entrypoint and offline flags; and the task_id/instance_id split Anatomy of a Task: the task row with name/description, priority/deadline, destination, status; schedule XOR trigger with repeat patterns; entrypoint and offline flags; and the task_id/instance_id split The model lives in [`unify/task_scheduler/types/task.py`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/types/task.py). `TaskBase` is the writable payload; `Task` adds identity: | Field | Meaning | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name`, `description` | Title and the detailed instruction. For agentic tasks the description **is** what gets executed. | | `status` | Lifecycle state — see the state machine below. | | `schedule` | Optional [`Schedule`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/types/schedule.py) holding `start_at`. | | `trigger` | Optional [`Trigger`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/types/trigger.py) — an inbound communication event that starts the task. | | `repeat` | Optional list of [`RepeatPattern`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/types/repetition.py)s. | | `priority`, `deadline` | [`Priority`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/types/priority.py) (`low`→`urgent`) and an optional due-by datetime — distinct from `start_at`, which is when work *begins*. | | `destination` | Where the row lives: personal by default, or a shared team pool via `team:` (see [storage](#storage-and-team-routing)). | | `response_policy` | Freeform contact-handling policy for the run; overrides contact-level policies where they conflict. | | `entrypoint` | `None` = **agentic** execution; a `function_id` = **symbolic** execution via a stored function (see [certification](#symbolic-entrypoints-and-offline-certification)). | | `offline` | Delivery lane only — `True` runs in the headless lane without waking a live session. Orthogonal to agentic/symbolic. | | `activated_by` | [`ActivatedBy`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/types/activated_by.py) (`schedule` / `trigger` / `explicit`) — set automatically at activation, never directly editable. | | `info` | Execution summary written on completion. | Two invariants matter constantly: * **Schedule XOR trigger.** A model validator rejects any row with both: *"A task cannot have both schedule and trigger."* A task either fires on the clock or fires on an event, never both. * **`task_id` vs `instance_id`.** One logical task can execute many times. The `task_id` is stable; each re-arm clones a fresh row with the next `instance_id`. "The weekly recap" is one `task_id` with a growing family of instances, each with its own status and history. ### Recurrence: a typed RRULE subset [`repetition.py`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/types/repetition.py) defines `RepeatPattern` as "a small subset of RFC-5545 RRULE expressed as first-class fields": `frequency` (`MINUTELY` through `YEARLY`), `interval`, `weekdays` (valid only with weekly frequency — a validator enforces it), `time_of_day`, and the two terminators `count` and `until`. Two functions do the real work: * `normalize_repeat_patterns` collapses evenly-spaced daily slot lists into a single minutely rule. * `next_repeated_start_at(previous_start, patterns, current_occurrence_index, now)` computes the earliest future occurrence, honoring `count`/`until` — this is what re-arm cloning calls to schedule the next instance. ## The lifecycle Task lifecycle state machine: scheduled and triggerable states flow into active via execute(), which fans out to completed, failed, and cancelled; execute() also clones the next instance back into scheduled/triggerable Task lifecycle state machine: scheduled and triggerable states flow into active via execute(), which fans out to completed, failed, and cancelled; execute() also clones the next instance back into scheduled/triggerable [`Status`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/types/status.py) is a six-value enum: `scheduled`, `triggerable`, `active`, `completed`, `cancelled`, `failed`. There is no separate FSM module — transitions are enforced by the scheduler's validation funnel: * Creation lands in `scheduled` (has `schedule.start_at`) or `triggerable` (has `trigger`); writing `active` directly on create or update is forbidden. * Only `TaskScheduler.execute` moves a row to `active`, stamping `activated_by` as it does. * Terminal states come from the run itself: [`ActiveTask.result()`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/active_task.py) writes `completed` (plus an LLM-generated `info` summary) or `failed`; `stop()` and cancel-classified interjections write `cancelled`. **Re-arm happens at execute time, not completion time.** When `execute` starts a `triggerable` task, it first clones an identical new `triggerable` instance (so the trigger stays armed while the current instance runs), then marks the current one `active`. Recurring scheduled tasks clone the next instance with `schedule.start_at = next_repeated_start_at(...)` — and if there is no next occurrence (a `count`/`until` boundary), no clone is made and the series ends. See `_clone_task_instance` in [`task_scheduler.py`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/task_scheduler.py). ## `TaskScheduler`: the manager [`TaskScheduler`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/task_scheduler.py) (extending the abstract contracts in [`base.py`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/base.py)) is the manager the actor reaches through `primitives.tasks.*`. Its public surface is deliberately small: | Method | What it does | | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ask(text)` | Read-only questions, answered by an LLM tool loop over the internal query tools. Returns a `SteerableToolHandle`. | | `update(text)` | Mutations via an LLM tool loop over the internal mutation tools. Returns a `SteerableToolHandle`. | | `execute(task_id, *, trigger_attempt_token=None)` | "Start one runnable task instance and return its live handle." Unlike `ask`/`update`, this is **not** a tool loop — it returns the `ActiveTask` handle directly. | | `create_task(name=..., description=..., destination=None)` | Thin non-LLM wrapper over row creation. | Inside the `ask`/`update` loops, the LLM composes internal tools: `_filter_tasks` (boolean expressions), `_search_tasks` (semantic vector search), `_reduce` (aggregates), `_create_task`/`_create_tasks`, `_update_task`, `_update_task_instance`, `_update_task_status`, `_delete_task`, `_cancel_tasks`, and `_get_task_or_raise`. The update loop also wires in `ContactManager.ask`, so "when Alice emails, text me" can resolve *Alice* to a contact id without leaving the loop. When the caller provides clarification queues, a `request_clarification` tool is injected too — this is how ambiguous edits ("move it to 8" — a.m.? which task?) bubble a question to the user instead of guessing. Every write funnels through the same validators regardless of entry point: schedule-XOR-trigger, `scheduled` requires `start_at`, `triggerable` requires `trigger`, no direct `active` writes, and active rows resist most mutation (`_ensure_not_active_task`). Prompts for the loops live in [`prompt_builders.py`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/prompt_builders.py). ## Storage and team routing All I/O goes through [`TasksStore`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/storage.py), which "centralises reads, writes, field management, and metrics." Four contexts (all under the `Assistants` project) make up the task system's durable state: | Context | Role | | -------------------------- | ----------------------------------------------------------------------------------------- | | `Tasks` | The user-authored task rows — the scheduler's primary read/write surface. | | `Tasks/Activations` | Machine-projected activation rows (when should what fire) — read-only inside the runtime. | | `Tasks/Runs` | Durable run records, one per execution. | | `Tasks/OutboundOperations` | Idempotency ledger for offline sends. | Team scoping rides the platform-wide destination system: a task created with `destination="team:"` is written under `Teams//Tasks` via `ContextRegistry.write_root`, while reads federate across the personal root plus every team the assistant belongs to (`ContextRegistry.read_roots`). During a run, the task's destination is exported as the `TASK_DESTINATION` environment variable so writes made *by* the run (secrets lookups, new rows) inherit the task's scope. ## Activation: three lanes Activation lanes: scheduled-live via activations, timers, and TaskDue into the ConversationManager; triggered-live via inbound messages, mechanical filter, semantic check, and primitives.tasks.execute; offline via dispatched jobs running offline_runner — all converging on TaskScheduler.execute returning an ActiveTask Activation lanes: scheduled-live via activations, timers, and TaskDue into the ConversationManager; triggered-live via inbound messages, mechanical filter, semantic check, and primitives.tasks.execute; offline via dispatched jobs running offline_runner — all converging on TaskScheduler.execute returning an ActiveTask A task row does nothing by itself. Activation — deciding *now is the moment* — arrives through one of three lanes, all converging on `TaskScheduler.execute`. ### Scheduled (live) Activations are projected into `Tasks/Activations`; a timer fires (Cloud Tasks in the hosted deployment, or the in-process [`LocalActivationScheduler`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/local_scheduler/scheduler.py) for self-host — "an asyncio-timer supervisor that fires scheduled tasks in-process", stateless across restarts, re-arming from wall-clock `next_due_at` on boot). The timer publishes a [`TaskDue`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/events.py) event carrying `task_id`, `scheduled_for`, an `activation_revision`, and a `visibility_policy` defaulting to `silent_by_default`. The handler in [`task_activation.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/task_activation.py) then: validates the delivery (`validate_task_due_activation` rejects stale revisions, mismatched `scheduled_for`, and revoked team memberships), records run provenance, and **auto-starts** the run via `scheduler.execute(..., _activated_by=ActivatedBy.schedule)` — registering the resulting handle in the ConversationManager's in-flight actions so the user (and the brain) can steer it. The `silent_by_default` policy is what produces the user-facing behavior documented in [How tasks run](/tasks/running): the run's notification tells the brain to "work silently unless you genuinely need the user." If the runtime was asleep, the wake reason is replayed on startup so a `TaskDue` never falls through the crack of a cold boot. ### Triggered (live) Trigger matching is deliberately two-stage: 1. **Mechanical** — when an inbound communication arrives, the event handlers call `_surface_trigger_task_candidates`, which lists armed trigger activations for that medium and filters by the trigger's `from_contact_ids` / `omit_contact_ids`. Cheap, deterministic, no LLM. 2. **Semantic** — surviving candidates are surfaced to the slow brain as a notification: "Semantic judgement is still pending. Decide whether this communication truly satisfies any candidate…" If the brain agrees, it calls the exact `primitives.tasks.execute(task_id=..., trigger_attempt_token="...")` line it was handed — the attempt token keeps the triggering inbound attached to the run it caused. So "when I get an email marked urgent, text me" costs nothing until an email actually arrives, one cheap filter pass on arrival, and one semantic judgment only for plausible matches. ### Offline (headless) Tasks with `offline=True` skip the live session entirely. The hosted path dispatches a job; self-host uses [`LocalOfflineDispatcher`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/local_scheduler/offline_dispatcher.py), which spawns `python -m unify.task_scheduler.offline_runner` as a subprocess. The [`offline_runner`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/offline_runner.py) is "intentionally small and procedural": it reads its contract from environment variables (built by [`offline_runner_contract.py`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/offline_runner_contract.py), shared with the hosted control plane), populates session details, runs `TaskScheduler.execute` against a fresh `CodeActActor`, and persists the terminal run state. Two behavioral differences from live runs: there is no ConversationManager to wake, and clarifications are disabled — the run's guidelines say plainly, "do not ask the user for live clarification." ## Execution: `ActiveTask` Whichever lane fired, `execute` wraps the run in an [`ActiveTask`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/active_task.py) — "a thin wrapper around an actor-backed active plan handle, keeping the corresponding Tasks row in sync." Under the hood it resolves an execution delegate from [`task_execution_context.py`](https://github.com/unifyai/unify/blob/main/unify/common/task_execution_context.py) (the live ConversationManager's actor, or the offline runner's) and falls back to a plain `actor.act(...)`. `ActiveTask` is a full **steerable handle** — the same steering contract as every other long-running operation in the runtime: * `pause()` / `resume()` delegate straight to the actor. * `interject(message)` first routes through `classify_steering_intent`, a small LLM router labeling the interjection `cancel` / `continue` / other — so "actually stop that" cancels the task row while "also include refunds" flows into the running plan. * `stop()` marks the row `cancelled`; `result()` marks it `completed` (writing the `info` summary) or `failed`, and updates the `Tasks/Runs` record either way. ## Symbolic entrypoints and offline certification Agentic execution re-plans from the description every run. For recurring work that has proven itself, the system can lock in a **symbolic executor**: a stored function from the [FunctionManager](https://github.com/unifyai/unify/blob/main/unify/function_manager/README.md) whose `function_id` becomes the task's `entrypoint`. The hook is wired at execution time: when a repeating or triggered agentic task runs, `TaskScheduler._build_task_entrypoint_review` attaches a post-run review context, and the actor (in [`code_act_actor.py`](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py)) gains two extra tools: * `attach_entrypoint_to_recurring_task(function_id, rationale, equivalence_manifest, anti_oversimplification_checklist)` — records the function as a **candidate** on future instances. It does not change delivery; future runs still execute live, now via the stored function. * `submit_offline_certification_evidence(function_id, certification_evidence, promotion_rationale)` — the gate to the headless lane. Promotion to `offline=True` demands a structured evidence dossier: a `risk_classification` (only `safe_noop`, `read_only`, and `idempotent_effectful` are promotable), input and equivalence contracts, a managed-primitive contract (no ad-hoc replacements of platform primitives), side-effect, idempotency, bounded cost, failure, and observability contracts, plus six explicit boolean attestations. The scheduler's `_offline_promotion_rejection_reasons` enforces the checklist, and there are at most two revision attempts. This is the machinery behind the user-facing claim that recurring work "gets sharper with repetition" — and the reason a 3am headless run can be trusted: nothing reaches the offline lane without a certified, bounded, idempotent executor. ## Local vs hosted scheduling The package cleanly splits *what fires* from *what decides when*: * **Hosted:** the closed-source control plane owns timing (Cloud Tasks) and job creation; the runtime uses a `NoopMaterializer` and just receives `TaskDue` events. * **Self-host / local:** [`settings.py`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/settings.py) auto-detects the environment (`LOCAL_SCHEDULER_ENABLED`), and [`materializer.py`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/local_scheduler/materializer.py) builds the `LocalActivationScheduler` (polling `Tasks/Activations` on a configurable interval, default 60s) plus the subprocess-based offline dispatcher. Same events, same semantics, single daemon. This is why the open-source install needs no Kubernetes and no cloud queue to run scheduled and triggered tasks. ## Testing and extending For deterministic tests and demos there is a storage-free drop-in, [`SimulatedTaskScheduler`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/simulated.py), with the same surface as the real manager. The test suite under [`tests/task_scheduler/`](https://github.com/unifyai/unify/tree/main/tests/task_scheduler) doubles as behavioral documentation — good entry points: * [`test_trigger.py`](https://github.com/unifyai/unify/blob/main/tests/task_scheduler/test_trigger.py) — trigger creation, schedule×trigger exclusion, clone-on-execute re-arm. * [`test_repetition.py`](https://github.com/unifyai/unify/blob/main/tests/task_scheduler/test_repetition.py) — `next_repeated_start_at`, recurrence cloning, offline promotion. * [`test_execute.py`](https://github.com/unifyai/unify/blob/main/tests/task_scheduler/test_execute.py) — execution, provenance, trigger tokens, entrypoint review. * [`test_local_scheduler_integration.py`](https://github.com/unifyai/unify/blob/main/tests/task_scheduler/test_local_scheduler_integration.py) — end-to-end local timer → `TaskDue`. * [`test_offline_runner.py`](https://github.com/unifyai/unify/blob/main/tests/task_scheduler/test_offline_runner.py) — the headless lane's lifecycle. If you're extending the system, the grain to follow: new *query/mutation* capabilities belong as internal tools on the `ask`/`update` loops; new *activation sources* belong as events handled in `task_activation.py`; new *execution styles* belong behind the `entrypoint` field and the certification gate — and everything durable belongs in the four `Tasks*` contexts, never in process state. # Monitoring tasks Source: https://docs.unify.ai/tasks/monitoring The Tasks tab — every definition and every run, in one place The **Tasks** tab lives in your assistant's workspace rail, and it exists to answer two questions at any moment: *what standing work does this teammate have*, and *how has it been going?* In the tab's own words, it holds "scheduled, recurring, triggered, and continuous workflows — definition and run history together." That "together" is the design: there's no separate activity page to cross-reference. Each task carries its own history, so the setup and the track record are always one click apart. (The ⓘ button in the tab header opens a quick "How to use this tab" guide if you ever want the refresher in-product.) ## The task list Tasks appear as expandable cards. Collapsed, each shows the essentials on one line: the task's name, its cadence in plain words ("Every week on Mon at 08:00", "Every 30 minutes"), when it next runs, its priority, and a status badge. The footer keeps the running tally — how many tasks exist and how many runs have been logged. Use the **All / Active / Paused** filter and the search box to narrow things down, and the refresh button to pull the latest. When any task is mid-run, a pulsing **Working** indicator appears in the toolbar. Expand a card and it splits in two: * **The definition** (left) — the plain-language description your assistant works from, plus the structured facts: **Type** (Scheduled, Triggered, On demand, Offline), **Trigger** (which event arms it, for triggered tasks), **Cadence**, **Start**, **Next run**, and **Priority**. * **Run history** (right) — every past run in a compact table: its state, why it started ("On schedule", "Triggered by Email", "Started on demand"), when it started and finished, and how long it took. Click any run to open the **Run Detail** view — "what happened, why it started, and when it ran": the task it belongs to, its status, the contact and channel that triggered it where relevant, and the precise timing from scheduled-for through completed-at. ## What the statuses mean | Badge | Meaning | | ------------- | ----------------------------------------------------- | | **Scheduled** | Will start automatically at its next scheduled time | | **Ready** | Armed and waiting for a matching event | | **Running** | Actively executing right now | | **Completed** | Finished successfully | | **Failed** | Stopped because something went wrong during execution | | **Cancelled** | Was stopped before it finished | | **Pending** | Created and waiting to start | ## What to use the tab for In practice, the Tasks tab earns its place in a few recurring moments: * **Right after creating a task** — confirm it landed the way you meant: the description reads right, the cadence parsed correctly, the next run is when you expected. * **The "did it run?" check** — a glance at run history answers whether last night's job fired, how long it took, and whether it succeeded. * **Triaging a failure** — a **Failed** badge plus the run's detail view tells you when and how a run died, so you can ask your assistant a precise question about it. * **Auditing the standing workload** — a periodic scroll through the list keeps the set of recurring jobs intentional rather than accreted; anything stale is one chat message away from being cancelled. ## Watching a run live Run history tells you what happened; the [**Actions** tab](/learning/watching-it-work) shows what's happening. When a task fires, its work streams there in real time — handy the first time a new task runs, or when you've hit **Test it** on a trigger and want to watch the dominoes fall. ## Making changes The Tasks tab is a window, not a control panel — creating, editing, pausing, and deleting all happen [by asking your assistant](/tasks/creating). If something in the list looks off, the fastest fix is to say so: *"the Friday recap ran twice last week — what happened?"* # Overview Source: https://docs.unify.ai/tasks/overview Standing work your assistant does while you're away Ask your assistant to do something and it does it now. **Tasks** are for everything else — work that should happen *later*, *repeatedly*, or *whenever something happens*, without you being there to ask. Set up a task once, in plain language, and your assistant handles it from then on: it wakes up on its own, does the work quietly, and reaches out on one of your [connected channels](/communication/overview) with the result. The proof a task is working isn't a row in a table — it's your assistant returning unprompted. ## The kinds of tasks Runs on the clock — once at a specific time, or on a recurring routine. *"Every morning at 8am, send me a calendar rundown."* Runs when something happens in your world. *"When I get an email marked urgent, text me straight away."* A standing task you fire whenever you like — "run the weekly recap now". Runs quietly behind the scenes without opening a live session — you only hear about it if it has something to send you. A task is either scheduled *or* triggered, never both — but you can have as many tasks as you like, and they run independently. ## What tasks can do A task is a plain-English description of work, and when it runs your assistant brings its full toolkit: your [workspace](/workspace/overview) (inbox, calendar, files), your [integrations](/integrations/overview), the web, every [communication channel](/communication/overview) you've connected, and the [data tables and dashboards](/canvas/overview) it maintains for you — recurring tasks that log fresh data each run are how live dashboards stay live. Some favorites: * *"In two minutes, check my inbox and text me anything urgent."* * *"Every Friday afternoon, recap my week and email it to me."* * *"Each morning, check HubSpot for new leads and message me the ones worth a call."* * *"When someone messages me on Slack after 6pm, send me a WhatsApp."* * *"When a calendar invite lands for tomorrow, give me a heads-up here."* ## Tasks vs. everything else * **Tasks vs. Actions.** The **Actions** tab shows live work happening right now — including a task mid-run. The **Tasks** tab holds the standing definitions that fire on their own. * **Tasks vs. asking in chat.** "Summarize my inbox" is a one-off request. "Summarize my inbox every morning" is a task. Your assistant sorts out which is which from how you phrase it. * **Tasks vs. your to-do list.** Platform tasks are work *your assistant* performs. Your personal Google Tasks / Microsoft To Do lists are [workspace to-dos](/workspace/contacts-and-tasks) — the assistant can manage those for you, but they're your list, not its job queue. * **Reminders are tasks too.** "Remind me to call Alice tomorrow at 9" just creates a small scheduled task that pings you at 9. ## What's in this section Describe the work in chat — schedules, triggers, and testing. Waking on time, working quietly, reporting back, and what it costs. The Tasks tab — definitions, statuses, and run history. # How tasks run Source: https://docs.unify.ai/tasks/running Your assistant wakes on its own, works quietly, and reports back Once a task exists, you can forget about it. Everything from waking up to reporting back is your assistant's job. ## It wakes up by itself You don't need to be online, in the Console, or even awake. When a scheduled task comes due — or a trigger event arrives — the platform wakes your assistant automatically and the work begins. If your assistant happens to be mid-conversation with you already, the task simply runs alongside without interrupting. ## It works quietly Tasks run **silently by default**. Your assistant doesn't announce "starting your task now" or narrate its progress — it just does the work, and gets in touch when there's something worth saying: * the **result**, delivered on the channel the task specifies (email, text, WhatsApp, chat — your call), or * a **question**, if it genuinely can't proceed without you. If you're curious while a task is running, the **Actions** tab shows the work live, step by step — and the Tasks tab shows a **Working** indicator whenever a run is in progress. ## It gets sharper with repetition For recurring work, your assistant doesn't start from a blank page each time. Once a routine has run successfully a few times, the platform can lock in the proven approach so future runs follow the same reliable steps — your Friday recap looks and behaves consistently, week after week. This is part of the broader [learning system](/learning/overview). ## Timing details worth knowing * **Timezone** — recurring times like "every morning at 8" follow your assistant's timezone unless the task says otherwise. * **Recurring tasks reschedule themselves** — when a run finishes, the next occurrence is queued automatically. * **Stale tasks don't misfire** — if you cancel or edit a task before it fires, the old schedule is quietly dropped; you won't get a ghost run. ## When a run fails If something goes wrong mid-run, the run is marked **Failed** in the task's history with the error recorded — failures are visible, not swallowed. Ask your assistant what happened and it can tell you, fix the problem, or rerun the work. ## What tasks cost A task run consumes credits exactly like the same work would in a live conversation — there's no separate task fee, and setting up tasks is free. Two consequences: * **Spending caps apply.** A 3am run counts against the same monthly caps as daytime chat. If a cap is hit, the run is blocked rather than billed past the limit. * **No credits, no run.** If your balance is empty, tasks pause until you top up — your assistant will tell you if that happens rather than failing silently. # Developers Source: https://docs.unify.ai/teams/developers How team scoping and shared context actually work inside the open-source unify runtime This page is for engineers reading, extending, or debugging the shared-team machinery in the open-source [`unifyai/unify`](https://github.com/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:` 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. Two scopes, one registry: state managers pass a destination string to ContextRegistry, which resolves it to either the personal root or a membership-gated team root — with the same table names under every root Two scopes, one registry: state managers pass a destination string to ContextRegistry, which resolves it to either the personal root or a membership-gated team root — with the same table names under every root There are two kinds of root for a personally-supervised assistant, and a third shape for [team assistants](/teams/team-assistants): * **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 as `SESSION_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's `Teams/{owner}/Tasks/…` tree for the same reason. The root itself is resolved in [`unify/common/runtime_context.py`](https://github.com/unifyai/unify/blob/main/unify/common/runtime_context.py)'s `resolve_runtime_context_root()`. The arbiter is `ContextRegistry` in [`unify/common/context_registry.py`](https://github.com/unifyai/unify/blob/main/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 in `SESSION_DETAILS.team_ids` (sorted), provisioning any that don't exist yet. Reads always fan out. For a **team-owned** session both primitives collapse onto the team shape: `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:"` 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`: 1. **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."* 2. **Membership.** `team_id not in SESSION_DETAILS.team_ids` fails 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 is `SHARED_SCOPED_TABLES` in [`unify/common/authorship.py`](https://github.com/unifyai/unify/blob/main/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 same `authorship.py` module handles this: * `fields_with_authoring(...)` injects an immutable `authoring_assistant_id` column into every shared-scoped table's schema — applied automatically by `ContextRegistry._get_contexts_for_manager` whenever it provisions a shared-scoped context. * `stamp_authoring_assistant_id(entries)` stamps write payloads with the active assistant's id (from `SESSION_DETAILS.assistant.agent_id`), and `strip_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 Write path: a destination string is normalized, membership-gated, and resolved to exactly one root. Read path: read_roots fans out across the personal root and every member team, merged by federated search into one global window Write path: a destination string is normalized, membership-gated, and resolved to exactly one root. Read path: read_roots fans out across the personal root and every member team, merged by federated search into one global window ### 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: 1. normalizes via `canonical_destination`, 2. rejects non-shared tables and non-member teams, 3. lazily provisions the context under the resolved root (`_ensure_context` → `_create_context_wrapper`), registering explicit ownership — `_owner_for_root` maps `Teams/{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, 4. returns the single root context the write targets. A write therefore lands in **exactly one scope**. There is no "write to personal and team" — a deliberate invariant that keeps provenance and deletion semantics simple. **One exception, by design:** transcripts and images. Conversation history isn't authored *at* a destination — it happens — so `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`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/managers_utils.py) and the screenshot capture path in [`unify/conversation_manager/conversation_manager.py`](https://github.com/unifyai/unify/blob/main/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 is [`unify/common/federated_search.py`](https://github.com/unifyai/unify/blob/main/unify/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, a `source` label, an optional `row_filter`, optional `allowed_fields` / `excluded_fields` projections, and an optional foreign `project` (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. `SortSpec` handles the subtle NULLs-ordering problem: the backend sorts NULLs last, so a sort key asking for `missing="first"` forces full branch fetches. A `fetcher` escape 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 with `limit=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 of `offset + 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 via `merge_ranked_batches`, and sliced once. Optional `backfill` tops 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. The same server endpoint powers the Console's merged **All** scope views (Functions, Transcripts, and friends), so what the UI shows and what the runtime reads are the same exact merge. Merged rows are annotated with `_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`](https://github.com/unifyai/unify/blob/main/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`](https://github.com/unifyai/unify/blob/main/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`](https://github.com/unifyai/unify/blob/main/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 `.env` mirror. * **Tasks** — [`unify/task_scheduler/task_scheduler.py`](https://github.com/unifyai/unify/blob/main/unify/task_scheduler/task_scheduler.py). A team task lives in `Teams/{id}/Tasks` (the `Task` model carries its `destination`), but *execution* belongs to the creating assistant. Scheduled/offline runs export the task's vault as the ambient destination so `primitives.secrets` calls inside the run inherit the team scope — see the offline runner in [`unify/task_scheduler/offline_runner.py`](https://github.com/unifyai/unify/blob/main/unify/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.py`](https://github.com/unifyai/unify/blob/main/unify/dashboard_manager/base.py) adds a second axis: a tile's *row* has a `destination`, but its live data bindings take an independent `data_scope`, so a personal watch tile can legitimately read a team's data. ## How membership reaches the runtime Membership flow: Orchestra owns team membership and delivers team_ids and team_summaries in the startup payload to SESSION_DETAILS, which feeds the system prompt block and worker env vars; a mid-session AssistantUpdateEvent refreshes membership live and forget_departed_team_roots drops cached roots Membership flow: Orchestra owns team membership and delivers team_ids and team_summaries in the startup payload to SESSION_DETAILS, which feeds the system prompt block and worker env vars; a mid-session AssistantUpdateEvent refreshes membership live and forget_departed_team_roots drops cached roots ### Session state [`unify/session_details.py`](https://github.com/unifyai/unify/blob/main/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](/teams/team-assistants) (`None` = user-owned), with `SESSION_DETAILS.team_owned` as the derived flag that switches the registry, root resolution, task-machine routing, and prompt layer into team-owned mode. Because actor plans and manager tool-loops can run in worker subprocesses, all of it round-trips through the environment: `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 an `AssistantUpdateEvent` with `update_kind == "membership"` in [`unify/conversation_manager/domains/event_handlers.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/event_handlers.py), which: 1. replaces `SESSION_DETAILS.team_ids` / `team_summaries` and re-exports the env vars, 2. 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, 3. resets the cached prompt context so the next turn renders the new *Accessible shared teams* block. The result is the user-visible guarantee that revocation doesn't wait for the next session: a departed team's roots become unreachable immediately (writes fail the membership gate; reads stop fanning out to it). ## Teaching the model: the prompt layer Enforcement without instruction would produce an assistant that constantly bounces off `invalid_destination` errors. The instruction side lives in [`unify/common/accessible_teams_block.py`](https://github.com/unifyai/unify/blob/main/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:` 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 `destination` docstring restates it in domain-specific terms (see `DataManager.create_table` or `SecretManager.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 in [`unify/coordinator_manager/coordinator_manager.py`](https://github.com/unifyai/unify/blob/main/unify/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_id` adds a colleague assistant directly; `member_user_id` adds 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 structured `ToolError` envelopes on failure rather than raising. After any successful mutation, Orchestra pushes the membership refresh described above to affected live runtimes. ## 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_TABLES` in `authorship.py`. `ContextRegistry` will start accepting `team:` destinations for it, inject the authorship column on provisioning, and include team roots in `read_roots` fan-out. Then thread a `destination` parameter through your manager's write methods (pass it to `write_root`) and build reads on the federated helpers with `read_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.py` next to `federated_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_destination` payload (wrong token, non-shared table, or stale membership — it tells you which), `SESSION_DETAILS.team_ids` vs. the `TEAM_IDS` env var (a worker that didn't inherit the env), or a cached registry entry that `forget_departed_team_roots` should have dropped. `ContextRegistry.clear()` exists for test isolation; the `_registry` cache 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](https://github.com/unifyai/unisdk). # Managing teams Source: https://docs.unify.ai/teams/managing Creating teams, membership admin, and org-wide sharing Team administration lives under **Organizations → Teams** in the Console, alongside Members and Roles. Creating and changing teams requires org admin permissions (Owner or Admin role). ## Creating and editing teams * **Create new team** — give it a name and an optional description, and you're done. You're added as the first member automatically — and so is your T-W1N, so the team's shared pool is usable immediately. * **Write the description well.** It's more than a label: assistants read team descriptions when [deciding where content belongs](/teams/shared-context), so "Repairs — work orders, contractor coordination, and tenant communication for the housing patch" routes far better than "Team 2". * **Update or delete** from the team's row menu. Deleting a team is consequential: it permanently removes the team's entire shared pool — the knowledge, playbooks, tasks, and data accumulated in it. * **Teams that own assistants can't be deleted.** If the team has [team assistants](/teams/team-assistants), its pool is their entire memory — deletion is refused until those assistants are deleted first, so a team's AI staff can never be wiped out as a side effect. ## Managing members From each team's menu: * **Add member / Remove member** — org members join and leave the team. Each person's T-W1N [follows them in and out](/teams/membership) automatically. * **Assistant members** — specialist assistants are added to teams in their own right. The simplest way is to ask your org's T-W1N — org-shaped setup like team creation, membership, and shared credentials is exactly the work it's for. * **Hire directly into the team** — the team's **Members** panel has a **Hire for this team** action that onboards a new [team assistant](/teams/team-assistants) with this team preselected as its owner. (A team assistant can't be removed from its owning team — that membership is structural.) Access changes take effect immediately, including for assistants that are mid-session. ## Team spend Assistant spending is attributable per team: the organization's spending breakdown can be scoped to any team, aggregating across every [team assistant](/teams/team-assistants) that team owns. Their caps are managed by org admins, bounded by the organization's cap, and limit alerts go to the org's Owners and Admins — the full model is described in [Team assistants](/teams/team-assistants#spend-caps-and-attribution). ## Org-wide sharing: on by default New organizations start with org-wide sharing **enabled** — choose **private** at creation (or toggle later from the Teams tab) to opt out. The Console explains the trade directly: > By default, all skills acquired and knowledge retained are personal to > each user's own private teammate… Each user's teammate learns in > isolation, based on that user alone. If you'd like the teammates to also > have the option to read/write from a shared pool across the org where > appropriate, select shared below. > > If you want more granular control, then it is best to set up dedicated > teams. Users and teammates can then be assigned to teams to enable > controlled sharing within specific teams, rather than blanket org-wide > sharing. Org-wide sharing runs through a managed team named **Org** that automatically includes every current and future member and assistant. A few things to know about it: * **It's managed.** The Org team can't be renamed, edited, or deleted by hand — it exists exactly as long as org-wide sharing is on. * **It's still selective.** Even in shared mode, assistants decide what's genuinely useful to share versus personal — and **transcripts, emails, and files are never shared** through the org pool. Only knowledge, skills, and general know-how flow into it, creating what the Console calls "a faster hive-mind approach to learning across the team." * **It's the natural home for company-wide assistants.** Hiring a [team assistant](/teams/team-assistants) into the Org team gives you an assistant that belongs to — and is usable by — the entire organization, present and future. * **Turning it off deletes the pool.** Disabling org-wide sharing removes the Org team *and all shared content in it* — the Console warns you, and it cannot be undone. (Like any team, it can't be disabled while it owns team assistants.) ## Choosing your structure | Setup | Fits | | --------------------------------------- | -------------------------------------------------------------------------------------------- | | **Fully private** (opt out at creation) | Solo users; orgs where assistants genuinely shouldn't share | | **Org-wide sharing only** (the default) | Small orgs where everyone works on everything | | **Dedicated teams** | Any org with units, divisions, or sensitivity boundaries — the recommended shape as you grow | The options combine: you can run org-wide sharing for general know-how *and* dedicated teams for division-specific or sensitive material — the narrower pool keeps its boundary regardless. # People & assistants Source: https://docs.unify.ai/teams/membership Who belongs to a team, and how assistant access follows Teams contain **people** and **assistants**, and the platform treats the kinds of assistants — your private T-W1N, personally-supervised hires, and [team assistants](/teams/team-assistants) — very differently. Getting these distinctions right is most of understanding teams. ## Your T-W1N: private, and it follows you Everyone in an organization has their own **T-W1N** — their digital twin. Two rules define how it relates to teams: 1. **It's hidden from everyone else.** Your T-W1N doesn't appear in colleagues' assistant lists, can't be messaged by them, and can't even be opened by an org admin. It works for you and answers to you, full stop. 2. **It inherits your team access — exactly.** When you're added to a team, your T-W1N automatically becomes a member too; when you leave, it leaves with you. You never manage its memberships, and it can never have access you don't have. The result: your twin can read and contribute to the shared pools of every team *you're* on — the sales team's playbooks, the ops team's data — while remaining completely invisible to your colleagues. They benefit from what it shares into team pools without ever interacting with it directly. ## Hired assistants: visible, and members in their own right Hired assistants are the opposite on both counts: 1. **They're visible across the organization.** Colleagues (with the appropriate org permissions) can find them in the assistant list, open their profiles, and message them — they're shared team members in the fullest sense. 2. **Their team memberships are their own** — granted explicitly, not inherited from any person. One assistant can serve several teams at once (the sidebar shows a badge listing all of them). Within that, two ownership models: * **Team assistants** — owned by a team rather than a person. Hiring defaults to this: you pick an **owning team** during the hire, the team becomes the assistant's home, and membership in that team is *structural* — it can't be removed from its owning team, and the team can't be deleted while it owns assistants. Everything about them is covered in [Team assistants](/teams/team-assistants). * **Personally-supervised assistants** — owned by the person who hired them (choose **Personal** during the hire). They keep a private memory as their [privacy floor](/teams/shared-context) and join teams as an add-on: each membership layers that team's shared pool on top. This is what makes hired assistants useful as **specialists**: a repairs coordinator serving the whole repairs team, a reporting assistant embedded in finance. They belong to the unit, not to a person. For how these teammates come to exist — and how their identity differs from a T-W1N's — see [Hiring](/hiring/overview). ## Joining and leaving Membership is live — access tracks it immediately: * **Joining** a team gives a member (human or assistant) access to the team's whole [shared pool](/teams/shared-context) — knowledge, guidance, skills, tasks, data, credentials — from that moment. It also puts them in the team's group chat. * **Leaving** (or being removed) ends that access just as cleanly. The shared content itself **stays with the team** for the remaining members — people take their access away with them, not the team's accumulated know-how. * **The one structural exception:** a [team assistant](/teams/team-assistants) cannot leave or be removed from its *owning* team — that team's pool is its entire memory. It can join and leave any *other* team freely, like any assistant. * **Leaving the organization** removes a person from all its teams — and their T-W1N from all of them too, automatically. Running assistants are told when their team access changes, so a revocation doesn't wait for the next session. ## Who's who at a glance | | Your T-W1N | Personally-supervised | Team assistant | | --------------------- | ------------------------- | ------------------------------------- | ----------------------------------------------- | | Visible to colleagues | Never | Yes — listed, searchable, messageable | Yes — listed, searchable, messageable | | Owned by | You | The person who hired it | The owning team | | Team membership | Automatic — mirrors yours | Explicit — granted per team | Owning team is structural; other teams explicit | | Private memory | Yes — its privacy floor | Yes — its privacy floor | None — the team pool is its memory | | Answers to | You | Its supervisor (and teams it joins) | Anyone on the team | # Overview Source: https://docs.unify.ai/teams/overview Group people and their assistants — and give them a shared brain An organization rarely works as one undifferentiated blob. Sales runs differently from engineering; the finance team's data is not the marketing team's business. **Teams** bring that structure to your assistants: a team groups **people and assistants** within your organization, gives them a **shared memory pool** — knowledge, playbooks, skills, tasks, data, and credentials that every member can use, and nobody outside can — and a **group chat** where all of them, human and AI, work together in one thread. ## Why teams exist Without teams, every assistant learns alone. Each one builds its own [knowledge, guidance, and skills](/learning/overview) from scratch, based only on the person it works with — perfect privacy, zero reuse. Teams unlock the middle ground that real organizations need: * **Learn once, share within the unit.** When one assistant on the team masters the expense process or the team's reporting SOP, every teammate's assistant can use that playbook — no re-teaching. * **Boundaries stay intact.** Shared material is visible to the team's *current members only*. Division-specific data, sensitive procedures, and elevated-access credentials stay inside the unit rather than spilling across the whole org. * **Privacy floors are respected.** Personally-supervised assistants keep personal memory as their [privacy floor](/teams/shared-context) — they share into a team pool only when content clearly belongs to that team, and ask when unsure. ([Team assistants](/teams/team-assistants) are the deliberate exception: they belong to the team, so the team pool *is* their memory.) * **A team can hire its own assistants.** A [team assistant](/teams/team-assistants) is owned by the team itself — no single supervisor, everyone can work with it, and everything it learns is shared with the team by construction. This shared-context machinery is the **most important thing teams do** — covered in depth in [Shared context](/teams/shared-context). ## Who's in a team Three kinds of members, with different rules: * **People** — colleagues from your organization, added by an org admin. Humans appear in the workspace roster with live online indicators, and you can message any of them directly. * **Everyone's T-W1N follows its person.** Your digital twin is [private to you](/teams/membership) — hidden from everyone else in the org — and it automatically joins and leaves teams *with you*, so it always has exactly your team access, never more. * **Hired assistants join in their own right** — and come in two ownership models: * **Team assistants** (the default when hiring) are *owned by a team*: the team's shared pool is their entire memory, anyone on the team can work with them, and no individual holds a supervisor position. See [Team assistants](/teams/team-assistants). * **Personally-supervised assistants** are owned by one person but can still be added to any number of teams, layering team pools on top of their private memory. Details and edge cases in [People & assistants](/teams/membership). ## Where you see teams The **Assistants** sidebar is a full org roster, grouped into three sections: **Teams** (each team with its members beneath it — an assistant serving several teams shows a badge listing them, and team-owned assistants carry a **Team-owned** badge under their owning team), **Real** (your human colleagues, with online indicators), and **Virtual** (assistants that aren't in any team). Your own T-W1N stays pinned at the top. **Selecting a team** opens the team's workspace: * **Chat** — the team's group chat, one thread shared by every member, human and AI. Messages behave like a conversation in any group channel: every member sees them, assistants read the room and reply when a message is genuinely for them (an @mention helps), and their replies are visible to — and can wake — their AI teammates. * **Members** — the roster with online status, and a **Hire for this team** action to onboard a new [team assistant](/teams/team-assistants) directly into it. * **Every brain section, team-scoped** — Tasks, Dashboards, Contacts, Transcripts, Knowledge, Functions, Guidance, and Data all show the *team's* shared pool rather than any single assistant's memory. **Selecting an assistant** gives the complementary view: each brain pane has scope chips — **All**, **Personal**, and one per team — so you can see its private memory, any single team pool, or the exact merged picture the assistant itself works from. Team administration lives under **Organizations → Teams** — see [Managing teams](/teams/managing). ## One org-wide team, on by default New organizations start with **org-wide sharing enabled**: a managed team called **Org** that automatically includes every current and future member and assistant. It's the simplest useful structure — one shared pool for general know-how, and the natural home for company-wide [team assistants](/teams/team-assistants). Organizations that need boundaries add dedicated teams on top (the narrower pool always keeps its boundary), or opt out to fully private at creation. All covered in [Managing teams](/teams/managing). ## What's in this section How membership works — T-W1N inheritance, hired assistants, joining and leaving. Assistants owned by the team itself — no single supervisor, shared memory by construction. The heart of teams: personal vs. team memory across knowledge, guidance, skills, tasks, data, and credentials. Creating teams, membership admin, org-wide sharing, and team spend. # Shared context Source: https://docs.unify.ai/teams/shared-context Personal by default, shared where it belongs — the heart of teams Everything an assistant accumulates — [knowledge and playbooks](/learning/overview), [skills](/learning/guidance-and-functions), [tasks](/tasks/overview), [data tables and dashboards](/canvas/overview), credentials — lives in a **memory pool**. Teams exist so that pool doesn't have to be all-or-nothing: every piece of context is either **personal** (belonging to one assistant) or **shared in a team** (accessible to every current member). One deliberate exception up front: [team assistants](/teams/team-assistants) have no personal scope at all — their owning team's pool *is* their memory. Everything below about choosing between personal and team applies to T-W1Ns and personally-supervised assistants; a team assistant simply defaults to its owning team and can additionally write into other teams it belongs to. ## The two scopes Private to one assistant. Working notes, individual preferences, personal credentials, anything whose audience is unclear. This is the default — the **privacy floor**. Shared with every current member of one specific team — its people, their T-W1Ns, and its specialist assistants. Team SOPs, shared reference data, unit credentials, common workflows. Nearly everything can take either scope: | What | Personal example | Team example | | --------------------- | --------------------------- | -------------------------------------------- | | **Knowledge** | Your private research notes | The team's operational reference data | | **Guidance** | How *you* like your reports | The SOP the whole unit follows | | **Functions** | A one-off helper script | The team's shared report-generation workflow | | **Tasks** | Your morning briefing | The team's weekly metrics run | | **Data & dashboards** | Your scratch analysis | The ops dashboard everyone checks at standup | | **Credentials** | Your personal API key | The team's shared service account | ## How assistants choose a scope You don't file anything manually — assistants route content as they work, under strict rules: * **Personal is the default.** Anything private, ambiguous, or individual-flavored stays personal. * **Team only when it clearly belongs.** Each team has a name and description; content goes to a team pool only when it plainly matches that team's domain. * **When unsure, they ask.** "Should this go in the Repairs team's shared pool, or keep it just between us?" — a brief question rather than a guess toward the wider audience. * **Credentials get extra caution.** Sharing a credential is harder to undo than sharing it later, so the bar for putting one in a team pool is highest of all. You can always direct scope explicitly: *"save this as team guidance for the repairs team"*, *"keep that analysis personal"*. ## How shared context gets used Here's the payoff. Whenever an assistant works, it draws on **its personal memory plus every team it belongs to** — searching for relevant playbooks, skills, knowledge, and credentials across all of them at once: * A new member's T-W1N can follow the team SOP **on day one**, because the playbook lives in the team pool, not in a veteran's head. * The expense workflow that one assistant [learned last month](/learning/overview) runs identically for everyone on the team. * The team dashboard is fed by team data tables that any member's assistant can update. Writing is the disciplined side: each saved item goes to **exactly one** scope — personal or one named team — never sprayed across pools. ## The controls that make sharing safe This design deliberately avoids "everything visible to everyone in the org": * **Team pools are invisible to non-members.** An assistant that isn't on the finance team cannot see finance's knowledge, data, or credentials — they don't appear in its searches at all. * **Membership is current-members-only.** Remove someone and their access ends [immediately](/teams/membership); the content stays for the rest of the team. * **There is no org-wide memory view.** Nobody — including admins — has a single pane showing every assistant's memory. Reviewing a team's shared pool means being on that team; reviewing an assistant's personal memory means being its owner. * **Sensitive stays scoped.** Elevated-access credentials, division-specific data, confidential procedures — put them in the narrowest team that needs them, and that's exactly how far they reach. ## Seeing scopes in the Console Every brain pane on an assistant — Contacts, Transcripts, Knowledge, Functions, Guidance, Tasks, Data, Dashboards — carries **scope chips**: * **All** (the default) — the exact merged picture the assistant itself works from: its personal pool plus every team it belongs to, combined into one correctly-ordered view. Merged entries show which scope they came from, and in Transcripts each thread is labeled with its team. * **Personal** — just its private memory. * **One chip per team** — just that team's shared pool. The complementary view is the team itself: **selecting a team in the sidebar** shows every one of those sections scoped to the team's pool — the same data any member's assistant sees when it reads that team, without going through any individual assistant. A good habit when teaching an assistant something reusable: say who it's for. "This is how *our team* handles refunds" routes the playbook to the team pool; "this is how *I* like my summaries" keeps it personal. The clearer the audience, the better the filing. # Team assistants Source: https://docs.unify.ai/teams/team-assistants Assistants owned by the team itself — no single supervisor, shared memory by construction Sometimes an assistant shouldn't belong to anyone. A repairs coordinator for the whole repairs unit, a reporting assistant the entire finance team leans on, a company-wide helper in the Org team — for these, "pick someone to own it and tell them to share everything" is the wrong shape. **Team assistants** make the team itself the owner: no single supervisor, no private memory, and everyone on the team can work with it as naturally as with a colleague. ## What makes a team assistant different No individual holds a supervisor position. The person who ran the hire is recorded only as the hiring member; management rights belong to the org's admins, like any shared resource. A team assistant has **no personal contexts at all**. Everything it knows, learns, schedules, and stores lives in its owning team's shared pool — visible to every member, by construction. The memory point is worth dwelling on, because it's a genuine privacy model, not just a default: * A personally-supervised assistant keeps a private pool and *chooses* when something belongs to a team. A team assistant has nothing to choose between — its knowledge, playbooks, skills, tasks, data, transcripts, and credentials all live at the team scope. There is no hidden layer only one person can see. * Its working state defaults to the **owning team's** pool. If it also joins other teams, it can read and contribute to those pools like any member — but its home never moves. * Because everything is team-visible, a team assistant is exactly as auditable as the team itself: open the team in the sidebar and every brain section shows the full picture. ## Hiring one Team-first is the default. The hire dialog includes an **Owning team** selector, pre-set to the team you hired from (or the managed **Org** team where org-wide sharing is on): * **Pick a team** → you get a team assistant, born into that team: it's enrolled automatically, appears under the team in the sidebar with a **Team-owned** badge, and its memory starts at team scope from the first message. * **Pick "Personal (only you)"** → you get the classic personally-supervised assistant instead, with a private memory and optional team memberships layered on. The fastest route for a specific team: open the team in the sidebar, switch to **Members**, and use **Hire for this team** — the owning team comes preselected. Everything else about hiring — profile, voice, its own accounts, phone numbers, and mailboxes — is identical to any hired teammate; see [Hiring](/hiring/overview). A team assistant still gets **its own identity** in your org's tools; what changes is who it answers to. ## Working with one Anyone on the team can message a team assistant directly, work with it in the [team group chat](/teams/overview#where-you-see-teams), assign it tasks, and see everything it's doing. There's no privileged channel — the hiring member's messages carry no more weight than anyone else's. Because its transcripts and tasks live at team scope, the whole team sees one coherent history: what it was asked, what it did, what it scheduled. Its scheduled work runs on the team's task surfaces, so runs and results are team-visible too. ## Teams, plural A team assistant always has **exactly one owning team** — that's where its memory lives and who it belongs to. On top of that it can join any number of other teams, exactly like any assistant: each extra membership adds that team's shared pool to what it can read and contribute to. Two structural rules keep ownership coherent: * **It can't leave its owning team.** Removing it from the owning team is refused — that pool *is* its memory. (Other memberships can be added and removed freely.) * **The owning team can't be deleted while it owns assistants.** Deleting the team would delete their entire minds; the platform refuses until the owned assistants are deleted first. Want an assistant the *whole company* can use? Hire into the managed **Org** team (on by default for new organizations). Its membership is automatically everyone — current and future — so "owned by the Org team" means "belongs to the company." ## Spend, caps, and attribution Team assistants bill the **organization's** account, and their cost controls are team-shaped rather than person-shaped: * **Caps are managed by org admins.** Any member with assistant-management permissions can set or change a team assistant's monthly spending cap — the hiring member holds no special position. * **The org cap is the ceiling.** A team assistant's cap is bounded by the organization's spending cap only — no individual member's personal cap constrains a team asset. * **Limit alerts go to the org's Owners and Admins** (alongside the hiring member), so the people who can act on a cap are the ones told when it's reached. * **Spend is attributable per team.** The organization's spending breakdown can be scoped to a team, aggregating across every assistant that team owns — so "what does the repairs team's AI staff cost?" is one query, not a spreadsheet. ## Which ownership model to choose | | Personally-supervised | Team assistant | | ----------------- | ------------------------------------------------------ | -------------------------------------------- | | Best for | An assistant that works *for you* with private context | A shared specialist the whole unit relies on | | Memory | Private floor + team pools it joins | Owning team's pool, entirely | | Who can direct it | Its supervisor; teams it joins | Anyone on the team | | Management rights | Its supervisor | Org admins | | Billing anchor | Supervisor's caps apply | Org cap; per-team attribution | | Lifecycle | Supervisor manages | Tied to the owning team | Both models coexist freely in one org — and in one team. The question to ask is simply: *if the hiring person left tomorrow, should this assistant carry on unchanged?* If yes, it's a team assistant. # Developers Source: https://docs.unify.ai/their-computer/developers How the assistant's computer actually works — the open-source unify internals Everything in this section so far describes what the assistant's computer does. This page explains **how**, at the code level, for developers using, extending, or auditing the open-source [unify](https://github.com/unifyai/unify) runtime. It covers the full stack: the `primitives.computer.*` surface the actor programs against, the HTTP backends beneath it, the agent-service that executes on the VM, the desktop composition itself, the readiness/lifecycle events, workspace file sync, execution surfaces, and the screen-share plumbing. All references are to the open-source repo; the hosted VM pool and Console are separate closed-source components that speak to these interfaces. ## The control stack The computer control stack The computer control stack Computer control is layered, with a clean seam at each level: 1. **Callers** — actor plans (via [`act`](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py)) and the ConversationManager's fast-path tools both invoke the same primitives. 2. **Primitives** — [`ComputerPrimitives`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/runtime.py) exposes three namespaces and owns readiness gating, secret injection, and session routing. 3. **Backends** — [`computer_backends.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/computer_backends.py) translates method calls into HTTP against an agent-service. 4. **agent-service** — a Node/Express wrapper around the Magnitude browser agent ([`agent-service/src/index.ts`](https://github.com/unifyai/unify/blob/main/agent-service/src/index.ts)), running Playwright sessions on or off the VM. A key design decision sits at the bottom: **desktop control is indirect**. The agent-service's desktop mode opens the VM's own noVNC viewer page in a headless Playwright browser and drives the desktop *through* it — mouse, keyboard, and screenshots all pass through the noVNC canvas, which guarantees that what the agent clicks and what it sees share one coordinate space. ## The primitives layer [`ComputerPrimitives`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/runtime.py) is a singleton (all actors share one backend) with three namespaces: | Namespace | Class | Semantics | | ---------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `primitives.computer.desktop` | `_ComputerNamespace` | The managed VM's display. **Singleton** — one desktop, one mouse; `create_session("desktop")` is forbidden. | | `primitives.computer.web` | `_WebSessionFactory` | Browser session factory: `new_session(visible=True)` → a visible Chromium on the VM (`web-vm` mode), `visible=False` → headless Chromium on the Unity host (`web` mode). Also `get_session(id)` and `list_sessions()`. | | `primitives.computer.user_desktop` | `_UserDesktopFactory` | A user's own linked machine — a separate consent-gated system, documented with [Your Computer](/your-computer/overview). | Two method sets define what each namespace exposes: `_COMPUTER_METHODS` (the full surface — `act`, `observe`, `query`, `navigate`, `get_links`, `get_content`, `get_screenshot`, the low-level input methods, `solve_captcha`, `execute_actions`) and `_DESKTOP_METHODS`, which strips `get_content` (the noVNC page's DOM is meaningless) and `solve_captcha` (web sessions only). Every call routes through `_make_session_method`, which does the load-bearing work: * **Readiness gating** — waits up to 300 s on the module-level `threading.Event` `_vm_ready` before any managed-VM call. Nothing touches the VM until the lifecycle pipeline (below) sets the event. * **Secret injection** — for `act`, `observe`, and `type_text`, the first argument passes through the SecretManager's `from_placeholder`, so plans can write `${SERVICE_API_KEY}` without the raw value ever appearing in LLM-visible text. * **Screenshot decoding** — `get_screenshot` returns a **PIL `Image`** (decoded from base64) so actor code can `display(...)` it or hand bytes to `query_llm(images=[...])`. * **Dead-session detection** — a `ComputerAgentError` with `error_type == "session_not_found"` (or closed browser/context/page messages) marks a `WebSessionHandle` inactive rather than retrying into a void. `WebSessionHandle` wraps one live session with the full method set plus `stop()`; handles carry `session_id`, `label`, `visible`, and `active`, and sessions can be re-attached later by numeric ID from a different actor run. ## Backends [`computer_backends.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/computer_backends.py) defines the contract and both implementations: * **`ComputerBackend`** — the ABC. The two headline methods have distinct contracts: `act(instruction, verify=False)` is *goal-level* (the agent plans multi-step UI work from vision; `verify=True` re-observes in a loop until the goal is confirmed), while `observe(query, response_format)` is strictly *read-only* extraction from the current screen, optionally into a Pydantic model. `query()` asks the session's own memory of past actions, not the live page. A `_LowLevelActionsMixin` provides `click`, `type_text`, `scroll`, `press_key`, tab management, and friends — all compiled to `execute_actions` payloads that run without any LLM in the loop. * **`ComputerSession`** — the per-session async HTTP client. Every method maps to one agent-service endpoint (`/act`, `/extract`, `/query`, `/nav`, `/screenshot`, `/links`, `/content`, `/execute-actions`, `/captcha/solve`, `/stop`), with the session ID injected into each payload and `SESSION_DETAILS.unify_key` as the Bearer token. Desktop sessions differ in two ways: `navigate` is expressed as an `act` ("Go to the page: …"), and `observe` forces `bypassDomProcessing` — screenshot-only vision. * **`MagnitudeBackend`** — the multi-mode factory. It holds **two base URLs**: `container_url` (the VM — serves `desktop` and `web-vm` sessions) and `local_url` (the Unity host — serves headless `web` sessions, default `http://localhost:3000`). `get_session(mode)` lazily creates one session per mode; parallel web sessions come from `create_session`. Errors are typed end-to-end: agent-service failures surface as `ComputerAgentError(error_type, message)`. * **`MockComputerBackend`** — a full in-process stand-in used by the test suite: canned `ActResult`s and a valid 32×32 PNG screenshot, so flows exercise the whole call path with no Playwright. `solve_captcha()` (web sessions) drives AntiCaptcha for reCAPTCHA v2 only, keyed by `ANTICAPTCHA_KEY` on the agent-service; the solved token is injected page-side and never returned to the caller. ## The agent-service The Node service in [`agent-service/`](https://github.com/unifyai/unify/blob/main/agent-service/src/index.ts) is the only process that touches Playwright. Highlights: * **Auth** is dual: the Bearer token must equal the service's own `UNIFY_KEY` *and* is verified against Orchestra's `/user/basic-info` — a stolen URL alone is useless. * **Session modes**: `POST /start` with `{mode: "desktop" | "web-vm" | "web"}`. Desktop mode waits for noVNC, opens the viewer page in Playwright, and instructs the Magnitude agent that it is controlling a VNC desktop. `web-vm` launches a visible Chromium on the VM's display; `web` launches headless Chromium wherever the service runs. * **Beyond the browser**: `POST /exec` runs shell commands (with `shell_mode: "powershell" | "cmd"` on Windows) and `POST /files` reads/writes under the `~/Unity/Local` workspace — these power the [execution surfaces](#execution-surfaces), not the browser sessions. * **Screenshots** come in two flavors: `POST /screenshot` (live capture, with cursor position) and `POST /screenshot/latest` (the cached frame from the last action — effectively free, used at high frequency during screen shares). ## The VM desktop itself [`deploy/desktop/`](https://github.com/unifyai/unify/blob/main/deploy/desktop/README.md) contains the desktop composition that runs on the VM, supervised by `supervisord`: | Component | Role | Port | | ------------------------------- | ------------------------------------- | ---- | | TigerVNC + XFCE4 (`desktop.sh`) | The actual display (`:99`, 1920×1080) | 5900 | | websockify + noVNC | Browser-facing viewer (`custom.html`) | 6080 | | agent-service | Automation API | 3000 | | sshd | SFTP endpoint for workspace sync | 2222 | The VNC password is derived from the first 8 characters of the assistant's `UNIFY_KEY` (legacy VNC caps passwords at 8 chars). In self-host, a Caddy reverse proxy ([`Caddyfile.selfhost`](https://github.com/unifyai/unify/blob/main/deploy/desktop/Caddyfile.selfhost)) maps the public shape used everywhere: `/desktop/*` → noVNC and `/api/*` → agent-service. That shape is the **`desktop_url` contract**: given a base URL, the Console live view is `{base}/desktop/custom.html` and the automation API is `{base}/api`. ## Lifecycle: from cold start to ready Managed desktop lifecycle Managed desktop lifecycle Desktop state lives on the global session singleton ([`session_details.py`](https://github.com/unifyai/unify/blob/main/unify/session_details.py)): `AssistantDetails.desktop_mode` (`"ubuntu"` / `"windows"`), `desktop_url`, and the `has_managed_desktop` property requiring both. Notably, the `StartupEvent` populates `desktop_mode` but **not** `desktop_url` — the URL is only set when the VM is actually reachable. Two events in [`events.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/events.py) drive the transition: * **`AssistantDesktopReady`** (`binding_id`, `desktop_url`, `vm_type`) — published by the hosting layer once the VM passes health checks. * **`FileSyncComplete`** — the initial workspace bisync has finished. The handler in [`event_handlers.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/event_handlers.py) guards against stale `binding_id`s and calls **`apply_managed_desktop_ready()`** in [`self_host_desktop.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/self_host_desktop.py) — the single convergence point for hosted and self-host paths. In order, it sets `SESSION_DETAILS.assistant.desktop_url`, publishes the liveview URL for the Console, sets the `_vm_ready` event and flips `cm.vm_ready`, warms the desktop session (`_ensure_desktop_session`, with exponential backoff), and kicks off file sync — publishing `FileSyncComplete` when the initial pass lands. Self-host deployments can shortcut the whole dance: `bootstrap_managed_desktop_on_startup()` probes `{api_base}/desktop/vnc.html` at boot and applies readiness without waiting for any event. Readiness gating is **prompt-level**, rendered by [`renderer.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/renderer.py): while pending, the system prompt carries `` ("Your managed desktop VM is still booting…") and then `status='sync_pending'` ("Files from previous sessions … DO NOT EXIST on disk yet"), each block disappearing as its flag flips. The behavioral contract — defer computer work while pending, follow up after `FileSyncComplete` — is pinned by [`tests/conversation_manager/core/test_infrastructure_readiness.py`](https://github.com/unifyai/unify/blob/main/tests/conversation_manager/core/test_infrastructure_readiness.py). ## Workspace file sync The VM and the Unity runtime share one workspace, kept in step by [`unify/file_manager/sync/`](https://github.com/unifyai/unify/blob/main/unify/file_manager/sync/manager.py): * **`SyncConfig`** ([`config.py`](https://github.com/unifyai/unify/blob/main/unify/file_manager/sync/config.py)) — rclone over **SFTP** to the host from `desktop_url`, port 2222, user `unityuser`, `~/Unity/Local` (from `get_local_root()` in [`file_manager/settings.py`](https://github.com/unifyai/unify/blob/main/unify/file_manager/settings.py)) ↔ `/Unity/Local` on the VM (`C:\Unity\Local` on Windows). Disabled entirely under a shared mount (`UNITY_DESKTOP_SHARED_MOUNT=1`). * **`RcloneSync`** ([`rclone.py`](https://github.com/unifyai/unify/blob/main/unify/file_manager/sync/rclone.py)) — `bisync` with `--conflict-resolve newer` (latest modification wins), automatic `--resync` recovery from corrupted baselines, plus per-file `copyto`/`deletefile` for write-through. * **`SyncManager`** ([`manager.py`](https://github.com/unifyai/unify/blob/main/unify/file_manager/sync/manager.py)) — fetches the SSH key from the backend, runs the initial `bisync(force_resync=True)`, then polls a 30-second bisync loop; `on_file_write`/`on_file_delete` hooks give writes immediate push semantics. It's wired into the FileManager through `LocalFileSystemAdapter.start_sync()`. The workspace layout the actor is told about (the filesystem-context table in [`actor/prompt_builders.py`](https://github.com/unifyai/unify/blob/main/unify/actor/prompt_builders.py)): | Path | Semantics | | -------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `Attachments/` | All exchanged attachments as `{id}_{filename}`; persists across sessions | | `Outputs/` | Staging for outbound files; cleared between sessions | | `Screenshots/Assistant/`, `Screenshots/User/`, `Screenshots/Webcam/` | Read-only frame archives; cleared between sessions | | `.env` | SecretManager's local mirror | | everything else | Persistent working files | ## Execution surfaces Shell and Python execution is orthogonal to the browser stack, organized by [`ExecutionSurface`](https://github.com/unifyai/unify/blob/main/unify/actor/execution/surface.py): `LOCAL` (the Unity host process), `ASSISTANT_DESKTOP` (the managed VM), and `USER_DESKTOP` (a linked user machine). Each has a target class in [`unify/actor/execution/targets/`](https://github.com/unifyai/unify/blob/main/unify/actor/execution/targets/assistant_desktop.py): * **`AssistantDesktopTarget`** runs commands through [`AgentServiceExecClient`](https://github.com/unifyai/unify/blob/main/unify/actor/execution/targets/exec_client.py) (`POST {desktop_url}/api/exec`), forcing `shell_mode="powershell"` when `desktop_mode == "windows"`. File movement rides the bisync. * **Windows-only functions**: stored functions flagged `windows_os_required=True` are routed by [`function_manager.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/function_manager.py) to the Windows VM — the wrapper script is staged into the sync root, bisynced across, executed via PowerShell, and the JSON result bisynced back. See [`tests/function_manager/python/test_remote_windows.py`](https://github.com/unifyai/unify/blob/main/tests/function_manager/python/test_remote_windows.py) for the full round trip. ## Screen share & remote control Screen share and remote control flow Screen share and remote control flow The Console's Meet controls arrive as system events — `AssistantScreenShareStarted/Stopped`, `UserScreenShareStarted/Stopped`, `UserWebcamStarted/Stopped`, `UserRemoteControlStarted/Stopped` (all in [`events.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/events.py)). The shared handler in [`event_handlers.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/event_handlers.py) flips the corresponding ConversationManager flags (`assistant_screen_share_active`, `user_remote_control_active`, …), pushes notifications to the slow brain and — during voice calls — guidance to the fast brain, and on screen-share start eagerly warms the desktop session so the first screenshot is instant. Three mechanisms hang off those flags: * **Fast paths.** `computer_fast_path_eligible` (true iff the assistant's screen is being shared) exposes the `desktop_act` / `web_act` tools in [`brain_action_tools.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/brain_action_tools.py). These deliberately **bypass the actor**: `_invoke_fast_path_action` calls the primitives directly in a background task, registers a minimal `_DesktopActionHandle` in the in-flight action set, silently interjects any running `act` sessions with what happened, and returns `{"status": "acting"}` immediately. One visible interaction per call — the constraint that keeps them snappy — is enforced by prompt contract. * **Remote control arbitration.** `ComputerPrimitives. set_user_remote_control(active, context)` broadcasts interjections into every registered actor queue, so running plans learn the user has the mouse (and later, that they released it). During user control, assistant-frame capture switches to cached frames (`cached=not user_remote_control_active` in [`medium_scripts/call.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/medium_scripts/call.py)); on release, a fresh live capture resets the cache. * **The screenshot pipeline.** While sharing, assistant frames are captured via `POST /screenshot/latest`, converted to JPEG, and pushed into `ConversationManager._screenshot_buffer`. On each slow-brain turn the buffer is drained, frames are written under `Screenshots/Assistant/` (paths from `generate_screenshot_path()` in [`cm_types/screenshot.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/cm_types/screenshot.py)), and attached to the LLM request as multimodal image content. Inside `act`, plans read the same files and reason over them with `query_llm(prompt, images=[...])` from [`common/reasoning.py`](https://github.com/unifyai/unify/blob/main/unify/common/reasoning.py). External meetings reuse the machinery: [`call_manager.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/call_manager.py)'s `_start_meet_screenshare` presents the VM's own noVNC page (`{desktop_url}/desktop/custom.html?password=…`) into a Google Meet or Teams call via the agent-service's meet routes. ## The actor's view [`ComputerEnvironment`](https://github.com/unifyai/unify/blob/main/unify/actor/environments/computer.py) registers the actor-facing tools — the desktop singleton methods, the web factory, and the user-desktop factory — and contributes the prompt context that teaches plans the ground rules: desktop is for native apps and anything already on screen, `web.new_session(visible=True)` is the default for browser work (`visible=False` for background lookups), screenshots are interpreted with `query_llm(images=...)` rather than `observe()` for pure perception, and desktop vs. web-session screenshots live in different coordinate spaces. Its `capture_state()` hands the steering system a `{type: "visual", screenshot, url}` snapshot so a paused action can be inspected mid-flight. ## Tests as specs The most instructive behavioral specifications, all under [`tests/`](https://github.com/unifyai/unify/blob/main/tests): | Test | Pins down | | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `conversation_manager/core/test_infrastructure_readiness.py` | Deferral while `vm_pending` / `sync_pending`, follow-up after `FileSyncComplete` | | `conversation_manager/core/test_event_handlers.py` | Desktop-ready and meet-interaction event handling, flag flips, screenshot-on-utterance | | `conversation_manager/actions/test_desktop_fast_path_routing.py` | Fast-path tool availability requires an active screen share | | `function_manager/primitives/test_remote_control_broadcast.py` | Remote-control interjections reach every actor queue | | `function_manager/python/test_remote_windows.py` | The Windows staging → bisync → PowerShell → result round trip | | `flows/test_computer_use_desktop.py` | End-to-end desktop computer use | ## Extending Three seams are designed for extension: implement a new **`ComputerBackend`** to swap the automation engine (the mock backend is the reference for the full contract); add an **`ExecutionSurface` target** to route shell/Python work to a new machine class; or stand up your own **agent-service** — anything that speaks the `/start`, `/act`, `/extract`, `/screenshot`, `/exec` protocol with the dual-auth scheme can serve a desktop. # Overview Source: https://docs.unify.ai/their-computer/overview Every teammate comes with its own computer Your assistant doesn't live inside a chat window. It has a **computer of its own** — a real desktop machine, running Ubuntu or Windows, with a real browser, real applications, and its own files. When you ask it to fill in a form on a website, wrestle a spreadsheet, or install and use a piece of software, this is where that work physically happens. > **Q: What software can you use?** > > **A:** I have my own computer and can download and use whatever software > is needed to get things done. That's your assistant's own answer during onboarding, and it's the right mental model: a remote colleague with their own machine. ## Why it matters Plenty of work can't be done through chat and APIs alone: * **Apps and websites with no integration.** If a tool has a screen, your assistant can use it the same way you would — signing in, clicking, typing, reading what's there. No [integration](/integrations/overview) required. * **Visual work.** Reviewing a page layout, checking what a form actually looks like, working through a flow that only exists in a browser. * **Files and software.** Downloading reports, converting documents, running tools, organizing folders — everything lands on its machine and [stays there between sessions](/their-computer/the-machine). * **Full transparency.** Because the work happens on a screen, you can [watch it live and even take the controls yourself](/their-computer/watching-and-control) during a Unify Meet call. ## Their computer, not yours This section is about the machine **your assistant owns**. Your own computer is a separate topic — covered in [Your Computer](/your-computer/overview): assistants can't see or touch your machine unless you explicitly link it, and by default the closest they get is watching your screen share on a call and talking you through the steps. As the assistant itself puts it: > Not directly — but you can view and control *my* computer through the > Meet window ("Show assistant screen" → "Enable mouse and keyboard > control"). If you need me to do something on my machine, just ask and > I'll do it. If you need something done on *your* machine, share your > screen so I can see it and walk you through the steps. ## What's in this section Browsing, apps, files, downloads — the assistant's day-to-day on its machine. See its live desktop during a Meet call, and drive it yourself. Ubuntu or Windows, always-there storage, and how it stays private. # The machine itself Source: https://docs.unify.ai/their-computer/the-machine Ubuntu or Windows, persistent storage, and privacy A few practical facts about the computer behind the screen — what it is, what survives between sessions, and who can see it. ## Ubuntu or Windows You choose your assistant's operating system **when you hire it**, in the **Computer** section of the hire form: a virtual machine running **Ubuntu** (the default) or **Windows**. Pick based on the software world your assistant will live in. If its work leans on Windows-only applications, choose Windows; otherwise Ubuntu is a great default. Day to day the experience is identical — same browser work, same file handling, same live screen in Meet calls. The choice is fixed after hiring, so it's worth a moment's thought up front. ## Always its machine, even when it's asleep Your assistant doesn't sit at its desk around the clock — the computer comes to life when there's work to do and rests when there isn't. What makes it *its* machine is that **everything persists**: * Its workspace lives on **dedicated storage that belongs to that assistant alone**. Files, downloads, installed tools, and half-finished work are all there the next time it sits down. * When the machine rests, the workspace is safely archived; when work resumes, it's restored automatically. You never manage any of this — from your side, the assistant simply *always has its computer*. Waking is quick, and the platform hides the seams: calls connect voice-first while the desktop spins up behind the scenes, and the [screen view](/their-computer/watching-and-control) unlocks mid-call the moment it's ready. ## Private by design * **One assistant, one machine.** Assistants don't share desktops or storage — each teammate's files and sessions are isolated from every other's. * **Viewing is gated.** The live desktop is only reachable through your Console session, during your call with your assistant. There's no public window into its screen. * **Control is opt-in and explicit.** Watching is read-only; taking the mouse and keyboard is a separate, deliberate toggle — and your assistant is notified the moment either starts or stops. * **Credentials stay out of sight.** Secrets your assistant uses (email, integrations) are injected securely at runtime — they're not sitting in files on the desktop, and they're never exchanged over a shared screen. ## Frequently asked **Do I need to set the computer up?** No. It arrives ready — browser, tools, and workspace included. If a job needs new software, your assistant installs it itself. **Does the computer cost extra?** There's no separate desktop fee — the machine is part of your assistant. Work it does there consumes normal usage credits, the same as any other work. **Can it use my computer instead?** Not by default — that's a separate, optional feature where you [explicitly link your own machine](/your-computer/overview). Out of the box, your assistant works on *its* computer and, at most, watches your screen share while guiding you on yours. # Watching & taking control Source: https://docs.unify.ai/their-computer/watching-and-control The Meet screen-share controls, exactly — and what to use them for Everything your assistant does on its computer can happen in front of you. During a [Unify Meet](/communication/unify-meet) call you can open a live view of its desktop, watch it work in real time, and — when you want hands on the wheel — take over its mouse and keyboard yourself. ## The controls, in the call toolbar The center of the Meet toolbar holds the screen-sharing controls: the button for sharing **your** screen, and next to it a two-button pill for the **assistant's** desktop. | Button | What it does | | -------------------------------------------------- | ------------------------------------------------------------------------------------- | | **Share your screen** | Streams *your* screen to the assistant (it can see, never click) | | **Show assistant screen** (computer icon) | Swaps the main call view from the assistant's avatar to its **live desktop** | | **Enable mouse & keyboard control** (pointer icon) | Appears only while the screen is showing — lets *you* operate the assistant's machine | The assistant-desktop buttons unlock in stages, and the tooltip tells you where you are: *"Available after assistant joins"* until the call is fully up, then *"Assistant desktop is starting up…"* while the machine wakes (calls connect voice-first, so you're talking well before this — the button enables mid-call, no reconnect needed), and finally **Show assistant screen** when everything's ready. ## Watching: the live desktop view Click **Show assistant screen** and the main call area becomes the assistant's actual desktop, streaming live — you'll see a brief *"Starting assistant screen sharing..."* and then the machine itself. Browser tabs opening, text being typed, the file manager, the terminal: whatever the assistant is doing, as it does it. A few things about the view: * **It's view-only by default.** A transparent guard sits over the screen — hover it and you'll see *"Enable interactive mode to take control"*. Your clicks touch nothing until you explicitly opt in. * **It scales with the window.** The desktop fits itself to the call window, whether that's the full-size dialog, a floating window, or docked above chat — and fullscreen is available for detail work. (If you shrink a floating call very small, the desktop controls tuck away until you enlarge it.) * **Your assistant knows you're watching.** The moment you open or close the view it's told — *"User enabled assistant screen sharing"* — so it narrates naturally while you watch and never treats the screen as private. * **Both directions at once.** Sharing your own screen doesn't conflict: your share (or camera) rides along as a small picture-in-picture in the corner while the assistant's desktop fills the main view, and you can maximize or minimize your self-view at any time. Click **Hide assistant screen** to return to the avatar; the assistant's work carries on either way. ## Taking control With the screen showing, the pointer button toggles **interactive mode** — you'll see *"Interactive mode enabled."* and the guard overlay lifts. From that moment your mouse and keyboard drive the assistant's machine directly: * **Your assistant steps back.** It's notified the instant you take over ("User took remote control of assistant desktop") and pauses its own computer actions rather than fighting you for the mouse. It stays on the call, watching and talking with you. * **You have the full machine.** Open and close apps, click through the browser, type in the terminal — anything the assistant can do at its desk, you can do from yours. * **Hand back any time.** Toggle again (*"View-only mode enabled."*) or just hide the screen. Your assistant is told control was released and picks the work back up, taking a fresh look at the screen to account for anything you changed. This is true joint work on one machine — you drive while it watches, it drives while you watch, swapping as often as you like within the call. ## What the mode is for **Seeing the apps it's using.** The live view is the honest answer to "what is it actually doing?" — watch it fill in the web form, work the spreadsheet, run the script. It's the natural companion to a first run of anything new: a freshly taught workflow, a new [task](/tasks/overview) firing, a tool it hasn't used before. **Navigating its filesystem — both ways.** The assistant's machine has a [persistent workspace](/their-computer/what-it-does) — the `Attachments` folder where your files land, the outputs it has produced, everything it has downloaded and built. The screen view is how you explore it: * **Watch it navigate.** Ask — *"show me what's in the project folder"*, *"open the report you generated last week"* — and watch it work through the file manager or terminal on screen, narrating as it goes. * **Or browse yourself.** Enable mouse & keyboard control and open the file manager with your own hands: poke through folders, open files, check what's actually on disk — exactly as you'd lean over a colleague's desk. (There's no separate file-transfer widget in the call; when you want a file *sent* to you, just ask — it arrives as a [chat attachment or email](/communication/overview) moments later.) **Demonstrating and fixing.** For fiddly procedures, take control and do it once while narrating — your assistant [learns from what it sees](/learning/teaching). And when something's gone sideways, taking the wheel to fix it directly beats describing the fix in words. ## Good habits * **Watch the first run of anything new** — a small correction on run one beats a cleanup on run ten. * **Talk while you drive.** The call is still live in both directions — the combination of your hands and your commentary is the richest teaching signal there is. * **Never type secrets on its screen.** Passwords and API keys belong in the secure [Integrations flows](/integrations/permissions), not typed into a shared desktop — your assistant will never ask you to. # What it does there Source: https://docs.unify.ai/their-computer/what-it-does Browsing, applications, and files — a working machine in daily use You never operate your assistant's computer for it — you just ask for outcomes, and it decides when the work needs a screen. Here's what that looks like in practice. ## Browsing the web The browser is the workhorse. Your assistant can open sites, sign in, navigate, fill forms, read pages, and download files — everything a person does in a browser tab: * *"Book the usual meeting room on the facilities portal for Thursday."* * *"Download last month's statement from the supplier site."* * *"Check what our product page looks like and tell me if the pricing table is out of date."* It can run **several browser sessions at once** — a visible one for work you might want to watch, and background ones for quick lookups that don't need an audience. It reads pages visually as well as structurally, so even awkward, JavaScript-heavy interfaces are workable. When a site throws up a CAPTCHA, the platform can solve the common kind automatically rather than stalling. ## Using applications Beyond the browser, the desktop itself is fair game: native applications, the file manager, the terminal. If a job needs software, your assistant can download, install, and use it — spreadsheet tools, document editors, whatever the task calls for. You don't need to provision anything. ## Working with files The assistant's machine has a persistent working folder that ties the whole platform together: * **Attachments arrive there.** Any file you send — in [Console chat](/communication/console-chat), by email, over WhatsApp — lands in its `Attachments` folder, ready to be opened and worked on. * **Downloads and outputs live there.** Files it pulls from the web, documents it produces, intermediate work — all on its machine, and it can send you any of them back over any channel. * **Nothing evaporates.** The workspace persists across sessions — the spreadsheet it built for you last week is still there when you ask for an update. More on this in [The machine](/their-computer/the-machine). ## When does it use the computer vs. everything else? You don't have to think about it — your assistant routes work sensibly. As a rule of thumb: * Connected [integrations](/integrations/overview) and the [workspace](/workspace/overview) come first when they cover the job — they're faster and more reliable than clicking through screens. * The computer takes over when there's **no integration for the tool**, the work is **inherently visual**, or **files and software** are involved. * The two combine freely: pull data from the CRM, work it over in a spreadsheet on the desktop, email you the result. Computer work also runs inside [scheduled tasks](/tasks/overview) — a weekly task can log into a portal, download the new report, and send you the highlights, screen and all, with nobody watching. # Calendar Source: https://docs.unify.ai/workspace/calendar Your assistant keeps an eye on your schedule Grant the **Calendar** feature when [connecting your workspace](/workspace/connecting) and your assistant can see and manage the connected account's calendar — Google Calendar or Outlook Calendar. ## What your assistant can do * **Review your schedule** — "what does my week look like?" gets a concise summary, with conflicts and free gaps flagged rather than a raw event dump. * **Create events** — "book an hour with Sam on Thursday afternoon" — with title, time, location, and attendees, so invites go out to everyone involved. * **Reschedule and update** — move events, change details, add people. * **Answer schedule questions** — "when am I free for a call with the supplier this week?" — it checks the calendar before answering, rather than guessing. ## Calendar + meetings Calendar access pairs naturally with your assistant's [meeting abilities](/workspace/meetings): * On Microsoft 365, it can **schedule a Teams meeting** directly onto the calendar — invites go out automatically and the join link is included. * Send it a meeting invite link and it can **join the meeting** as a participant. ## Good to know * Calendar is an **optional** feature — if you didn't grant it at connect time, open the Workspace dialog, tick **Calendar**, and click **Update Features**. * As with everything workspace-related, whose calendar this is depends on the connected account: T-W1N manages *your* calendar; a hired assistant manages *its own*. Try a recurring task: "every Sunday evening, send me a summary of my week ahead, and flag anything that clashes." # Connecting your workspace Source: https://docs.unify.ai/workspace/connecting Link a Google or Microsoft account in about thirty seconds The **Workspace** dialog in the Console handles the whole connection. You can open it from several places — they all lead to the same dialog: * Your assistant's profile → the **Workspace** tile * **Contact Details** → Email → **Configure** * The onboarding checklist's workspace step * The hire form, when setting up a new assistant ## The connect flow Choose **Google Workspace** (Gmail, Calendar, Drive) or **Microsoft 365** (Outlook, Teams, Calendar). Tick the features you want your assistant to have. Some are part of the core connection and always included; the rest are up to you: | Feature | Google | Microsoft 365 | | ------------------------- | --------------- | --------------- | | Email | Always included | Always included | | Drive / files | Always included | Always included | | [Teams](/workspace/teams) | — | Always included | | SharePoint | — | Always included | | Calendar | Optional | Optional | | Contacts | Optional | Optional | | Tasks | Optional | Optional | Click **Connect**. A new tab opens with the familiar Google or Microsoft sign-in, listing exactly the permissions you selected. Approve, and the tab returns you to the Console — you'll see "Workspace connected" along with which account is linked. If you granted file access, a **File access** picker appears so you can decide which folders and files your assistant can see. See [Files](/workspace/files) — by default it can access everything the connected account can, until you set rules. Connecting a workspace is **free** — unlike phone or WhatsApp channels, there are no setup or monthly credit costs. ## Changing what's granted Open the Workspace dialog any time to adjust: * **Add or remove features** — change the checkboxes and click **Update Features**. You'll go through the provider's consent screen again so the new permission set is explicitly approved by you. * **Change the account** — disconnect, then connect the other account. * **One account, one assistant** — a mailbox can only be connected to one assistant at a time. If you see *"That mailbox is already connected to an assistant"*, disconnect it there first. ## Disconnecting Click **Disconnect** in the Workspace dialog. This revokes the platform's access, removes the stored connection, and your assistant immediately loses the ability to act on that account — inbox, calendar, files, all of it. You can reconnect whenever you like. You can also revoke access from the provider's side at any time: **Google Account → Security → Third-party access**, or **Microsoft Account → Apps & permissions**. See [Access & security](/workspace/security) for the full picture. ## If you skip it Everything in [Communication](/communication/overview) that doesn't depend on a mailbox still works — Console chat, Unify Meet, phone, SMS, WhatsApp. But your assistant won't be able to read or send from the workspace mailbox, check the calendar, or reach files in Drive, OneDrive, or SharePoint until a workspace is connected. If you ask for something that needs it, your assistant will tell you what's missing and point you to the Workspace dialog — or walk you through it on a screen-share call. # Contacts & tasks Source: https://docs.unify.ai/workspace/contacts-and-tasks Your address book and to-do lists Two optional workspace features round out the connection: **Contacts** and **Tasks**. Grant either when [connecting](/workspace/connecting), or add them later with **Update Features**. ## Contacts With the Contacts feature granted, your assistant can look people up in the connected account's address book — Google Contacts or Microsoft contacts. That means when you say "email Priya about the invoice", your assistant can find the right Priya and her address from your contacts, rather than asking you to spell it out. This is read-only access to the workspace address book, and it's separate from your assistant's own contact list — the people it actively communicates with, each with their phone, email, WhatsApp, and preferences. Your assistant maintains that list itself as it works with you; the workspace address book is a reference it can consult. ## Tasks With the Tasks feature granted, your assistant can read and manage the connected account's to-do lists — **Google Tasks** or **Microsoft To Do**. * "What's on my to-do list today?" * "Add 'renew the insurance' to my tasks for Friday." * "Mark the invoice task as done." Don't confuse workspace tasks with your assistant's own [Tasks](/tasks/overview) tab in the Console. Workspace tasks are *your* to-do list in Google/Microsoft. Console Tasks are jobs you give the assistant itself — scheduled reports, recurring reminders, background work it performs for you. # Architecture Source: https://docs.unify.ai/workspace/developers/architecture How the Google Workspace / Microsoft 365 integration actually works in the open-source unify repo Everything in the [Workspace section](/workspace/overview) is implemented in the open-source [`unifyai/unify`](https://github.com/unifyai/unify) repository. This developer sub-section explains how it *actually works* — the modules, classes, and contracts — for anyone reading, extending, or self-hosting the code. The workspace integration is built from four cooperating pieces: 1. **Gateway OAuth adapters** — [`unify/gateway/adapters/google.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/google.py) and [`unify/gateway/adapters/microsoft.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/microsoft.py) — handle the OAuth callbacks when a user connects an account, exchange the authorization code for tokens, and persist them as assistant secrets. 2. **Runtime token plumbing** — [`unify/common/runtime_oauth.py`](https://github.com/unifyai/unify/blob/main/unify/common/runtime_oauth.py) plus the OAuth-aware parts of [`unify/secret_manager/secret_manager.py`](https://github.com/unifyai/unify/blob/main/unify/secret_manager/secret_manager.py) — mirror tokens into the running assistant and keep raw credentials out of everything the LLM can read. 3. **The provider proxy** — [`unify/provider_proxy/`](https://github.com/unifyai/unify/tree/main/unify/provider_proxy) — a localhost proxy that gives sandboxed actor code the *full* Google / Microsoft Graph REST surface without ever holding a real token, while enforcing the per-assistant file-access allowlist. 4. **Workspace surfaces** — the trusted [`unify/workspace_email/`](https://github.com/unifyai/unify/tree/main/unify/workspace_email) primitive for the connected mailbox, and the gateway channels ([`gmail`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/gmail/views.py), [`outlook`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/outlook/views.py), [`drive`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/drive/views.py), [`sharepoint`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/sharepoint/views.py)) that back sending, inbox watches, and the Console file picker. Workspace OAuth plumbing: the Console approves scopes, gateway OAuth callbacks persist GOOGLE_*/MICROSOFT_* tokens to Orchestra secrets, SecretManager syncs them into the runtime keeping raw tokens in memory only, and the localhost provider proxy swaps sandbox nonces for real tokens before calling Google APIs and Microsoft Graph Workspace OAuth plumbing: the Console approves scopes, gateway OAuth callbacks persist GOOGLE_*/MICROSOFT_* tokens to Orchestra secrets, SecretManager syncs them into the runtime keeping raw tokens in memory only, and the localhost provider proxy swaps sandbox nonces for real tokens before calling Google APIs and Microsoft Graph ## The core design decision One idea organizes the whole subsystem: **the LLM-controlled sandbox never holds a real OAuth token**. Sandboxed `execute_code` calls `get_oauth_access_token("google")` and receives a **nonce** — a capability handle valid only against the localhost proxy. The proxy, running in the trusted parent process, swaps the nonce for the real bearer token on each upstream request. Two consequences fall out of this: * Actor code gets the *entire* provider REST API (Drive, Calendar, Gmail API, Graph) with ordinary `httpx` calls — no bespoke per-endpoint tool surface to maintain. * Every file-shaped request funnels through one choke point where the [workspace file-access policy](/workspace/files) is enforced, uniformly, regardless of what code the LLM wrote. Trusted, non-sandboxed code (like `WorkspaceEmailManager`) bypasses the proxy and uses `get_provider_access_token`, which returns the real token — the split between those two functions in `runtime_oauth.py` *is* the trust boundary. ## End-to-end lifecycle 1. **Connect** — the Console builds an OAuth URL (scope bundles are assembled in the hosted control plane; see [the scopes note](/workspace/developers/gateway-channels#oauth-scopes)) and the user consents at Google/Microsoft. The provider redirects to `GET /google/auth/callback` or `GET /microsoft/auth/callback` on the gateway. 2. **Persist** — the callback (`google_oauth_callback` / `microsoft_oauth_callback`) validates the HMAC-signed `state` via `verify_oauth_state` in [`unify/gateway/adapters/oauth.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/oauth.py), exchanges the code, and writes the token set to Orchestra with `upsert_assistant_secrets`: `GOOGLE_ACCESS_TOKEN`, `GOOGLE_REFRESH_TOKEN`, `GOOGLE_TOKEN_EXPIRES_AT`, `GOOGLE_GRANTED_SCOPES`, `GOOGLE_ACCOUNT_EMAIL` (and the `MICROSOFT_*` equivalents, plus `MICROSOFT_TOKEN_SOURCE`). 3. **Mirror** — the running assistant pulls secrets back down through `SecretManager._sync_assistant_secrets`, with a hard split: raw tokens stay in an in-memory dict, while non-sensitive metadata (expiry, granted scopes) is mirrored to the environment. Details in [OAuth & secrets](/workspace/developers/oauth-and-secrets). 4. **Use** — actor code hits the proxy ([Provider proxy](/workspace/developers/provider-proxy)); trusted surfaces like `primitives.workspace_email.*` call providers directly ([Email & gateway channels](/workspace/developers/gateway-channels)). 5. **Refresh** — the runtime never talks to Google/Microsoft token endpoints itself. A platform refresh job keeps Orchestra's copies fresh; the runtime re-syncs on demand (`refresh_provider_access_token`) — for example after the proxy sees a 401. 6. **Revoke** — disconnecting calls `POST /google/revoke` (Google tokens are actively revoked upstream) and clears the Orchestra secrets; the next sync drops them from the runtime. ## What lives where | Concern | Open-source `unify` | Hosted control plane (closed) | | ---------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------- | | OAuth callbacks, token exchange | `unify/gateway/adapters/{google,microsoft,oauth}.py` | — | | Scope bundles (feature → scopes) | consumed, not defined | `unify-deploy/common/scopes.py` + Orchestra mirror | | Token storage of record | — | Orchestra assistant secrets | | Token refresh cron, watch renewal | — | hosted scheduled jobs | | Runtime token access + sandbox isolation | `unify/common/runtime_oauth.py`, `unify/provider_proxy/` | — | | Connected-mailbox primitive | `unify/workspace_email/` | — | | Send / watch / picker endpoints | `unify/gateway/channels/{gmail,outlook,email,drive,sharepoint}/` | mounted by the hosted comms app | | File-access policy storage | mirrored into `PolicyStore` | Orchestra `workspace-file-access` config | The rest of this sub-section walks each layer in depth: Token plumbing, the split storage model, and sandbox isolation. Request classification, the file-access policy engine, and response filtering. WorkspaceEmailManager, the Gmail/Outlook channels, watches, and the Drive/SharePoint pickers. # Email & gateway channels Source: https://docs.unify.ai/workspace/developers/gateway-channels WorkspaceEmailManager, the Gmail/Outlook channels, watches, and the file pickers This page covers the workspace *surfaces*: the trusted primitive for the user's connected mailbox, and the gateway channels that implement sending, inbox watches, and the Console's Drive/SharePoint file picker. ## Two send identities Email is the place where the twin-vs-teammate identity split ([user docs](/hiring/twin-vs-teammates)) becomes concrete in code — there are two entirely separate send paths: Two email identities: primitives.workspace_email acts as the user through WorkspaceEmailManager calling Gmail API or Microsoft Graph directly with a trusted token; primitives.comms.send_email acts as the assistant through the gateway email channels to its managed mailbox; inbound email arrives via Gmail push to /email/gmail and Graph subscriptions to /microsoft/router, waking the assistant Two email identities: primitives.workspace_email acts as the user through WorkspaceEmailManager calling Gmail API or Microsoft Graph directly with a trusted token; primitives.comms.send_email acts as the assistant through the gateway email channels to its managed mailbox; inbound email arrives via Gmail push to /email/gmail and Graph subscriptions to /microsoft/router, waking the assistant ### `primitives.workspace_email` — acts as the user [`unify/workspace_email/workspace_email_manager.py`](https://github.com/unifyai/unify/blob/main/unify/workspace_email/workspace_email_manager.py) defines `WorkspaceEmailManager`, registered as a primitive surface (not a full state manager) in [`unify/function_manager/primitives/registry.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/registry.py) and reached as `primitives.workspace_email.*`. Its public API: | Method | Notes | | ---------------------------------------------------------- | ------------------------------------ | | `send(to, subject, body, cc, bcc, in_reply_to, thread_id)` | Sends **from the connected account** | | `list_messages(query, max_results)` | Newest-first summaries | | `search(query, max_results)` | Alias of `list_messages` | | `get_message(message_id)` | Full message with plain-text body | Implementation notes for anyone extending it: * **Provider detection** (`_provider`) checks for Google state (granted scopes / in-memory token) first, then Microsoft. * It is **trusted code**: it calls `get_provider_access_token` for the real token and talks to `gmail.googleapis.com` / `graph.microsoft.com` **directly**, not through the provider proxy. * **Threading**: on Gmail, `in_reply_to` becomes RFC 5322 `In-Reply-To`/`References` headers and `thread_id` maps to Gmail's `threadId`; the Microsoft path currently sends via Graph `sendMail` without reply threading. * Errors surface as `WorkspaceEmailError` with user-actionable messages (e.g. no connected account available). ### `primitives.comms.send_email` — acts as the assistant The assistant's own mailbox goes the other way entirely: the runtime calls `send_email_via_address` in [`unify/conversation_manager/domains/comms_utils.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/comms_utils.py), which POSTs to the gateway's provider-agnostic dispatcher [`unify/gateway/channels/email/views.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/email/views.py) (`POST /email/send`). `_is_outlook_assistant` routes to the Gmail or Outlook channel based on the assistant's `email_provider` (`"google_workspace"` vs `"microsoft_365"` on `AssistantDetails` in [`unify/session_details.py`](https://github.com/unifyai/unify/blob/main/unify/session_details.py)) or the presence of a Microsoft token. ## The Gmail channel [`unify/gateway/channels/gmail/views.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/gmail/views.py) (mounted at `/gmail`, admin-key auth like all channels): | Endpoint | Handler | Purpose | | ----------------------- | -------------------- | ---------------------------------------------- | | `POST /gmail/send` | `send_email` | MIME build + `users().messages().send` | | `POST /gmail/watch` | `watch_email` | Gmail `users().watch` → Pub/Sub topic | | `DELETE /gmail/watch` | `delete_gmail_watch` | `users().stop` (run before revoking a token) | | `GET /gmail/attachment` | `get_attachment` | Fetch an attachment by message + attachment id | | `DELETE /gmail/delete` | `delete_email_user` | Admin SDK Directory user deletion | Send builds a `MIMEMultipart` message (attachments via base64 `content_base64`), resolves the display name with `_format_from_header`, and threads with both mechanisms: `In-Reply-To`/`References` headers (via `_as_message_id`) *and* Gmail's `threadId` in the send body. Credentials resolve through `_gmail_service_from_assistant` — a connected account's `GOOGLE_ACCESS_TOKEN` when present, else service-account delegation from `GCP_SA_KEY` (the platform-mailbox path). The watch targets the Pub/Sub topic from `_gmail_topic_path` (`gmail-notifications` + environment suffix); pushes land on the adapter's `POST /email/gmail` (`gmail_notification_processor` in [`unify/gateway/adapters/google.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/google.py)), which resolves the assistant by mailbox address, activates its runtime, and publishes an `email`-thread envelope carrying the Gmail `historyId`. ## The Outlook channel [`unify/gateway/channels/outlook/views.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/outlook/views.py) mirrors the shape with Microsoft Graph, via the client helpers in [`unify/gateway/common/graph.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/common/graph.py) (`graph_client_from_assistant` prefers the connected account's `MICROSOFT_ACCESS_TOKEN`, falling back to app-only admin credentials): | Endpoint | Handler | Purpose | | ------------------------- | ------------------------ | -------------------------------------------------------- | | `POST /outlook/send` | `send_outlook_email` | Graph `sendMail`, or `reply`/`createReply` for threads | | `POST /outlook/watch` | `watch_outlook_email` | Graph subscription on the inbox (`changeType="created"`) | | `DELETE /outlook/watch` | `delete_outlook_watch` | Remove the subscription | | `GET /outlook/attachment` | `get_outlook_attachment` | Attachment fetch | Details that matter: `in_reply_to` here is a **Graph message id**, not an RFC Message-ID; replies with attachments use the two-step `createReply` → attach → `send` dance because Graph's one-shot `reply` can't carry attachments; and subscriptions authenticate their callbacks with a `clientState` of `{OUTLOOK_WEBHOOK_SECRET}::{user_email}`. Inbound Graph notifications — Outlook *and* Teams — converge on one adapter entry point: `microsoft_router` in [`unify/gateway/adapters/microsoft.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/microsoft.py) (`POST /microsoft/router`), which answers the validation handshake and dispatches each notification by resource shape to `outlook_notification_processor_from_payload` or the Teams equivalent. Graph subscriptions expire (3-day max) and Gmail watches need periodic re-arming — the renewal crons live in the hosted control plane, not in the OSS gateway; self-hosters re-arm via the watch endpoints. ## Drive & SharePoint channels: the file picker's backend These two channels exist to power the Console's [file-access picker](/workspace/files) (and ancestry lookups). Both are read-mostly, BYOD-token surfaces: * [`unify/gateway/channels/drive/views.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/drive/views.py) — `GET /drive/roots` (My Drive + shared drives via `drives().list`), `/drive/children`, `/drive/item`, `/drive/search`. Items are identified by the `(drive_id, item_id)` pair — the same shape the proxy's policy decisions use, which is no coincidence. * [`unify/gateway/channels/sharepoint/views.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/sharepoint/views.py) — sites (`/sharepoint/sites`), drives (`/sharepoint/drives` covers OneDrive via the personal drive; `drive_id="me"` resolves through `_drive_ref`), folder listings by id or path, content download, upload, folder creation, deletion, and search. ## Teams meetings (workspace-adjacent) Meeting creation lives in [`unify/gateway/channels/teams/create_meeting.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/channels/teams/create_meeting.py): `create_instant_onlinemeeting` (Graph `/me/onlineMeetings`) and `create_scheduled_meeting_event` (Graph `/me/events` with `isOnlineMeeting=True`), exposed via `POST /teams/create_meeting`. Teams chat subscriptions follow the same watch pattern as Outlook (`_rebuild_teams_watches` in the teams channel). The rest of Teams is communication territory — see the [Communication developer docs](/communication/developers/gateway). ## OAuth scopes The **feature → scope bundle** catalogue (what "Email", "Drive", "Calendar" grant) is intentionally *not* in the open-source repo — it lives in the hosted control plane, which builds the consent URL. What the OSS code holds instead: * Channel-local constants for its own clients — `_GMAIL_SCOPES` and `_DIRECTORY_SCOPES` in the Gmail channel, `GRAPH_SCOPES` (`.default`) in `common/graph.py`. * The **granted** scopes, persisted at callback time as `GOOGLE_GRANTED_SCOPES` / `MICROSOFT_GRANTED_SCOPES` and consumed by the [actor's scope-checking discipline](/workspace/developers/oauth-and-secrets#scope-awareness). ## Self-host vs hosted The same channel and adapter code serves both deployments — `create_app(extra_routers=…)` in [`unify/gateway/app.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/app.py) lets the hosted comms app compose private infrastructure around it, and `GatewayContext` ([`unify/gateway/context.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/context.py)) injects the deployment-specific backends: envelope delivery (`HttpEnvelopeSink` to the local runtime vs a Pub/Sub transport), runtime activation (`LocalRuntimeActivator` no-op vs starting a cloud job), and public URL resolution for OAuth redirect URIs (`PublicUrlProvider`, surface `"adapters"`). Watch renewal and token-refresh crons are hosted concerns; a self-host stack polls or re-arms explicitly. ## Tests Gateway-side behavior is pinned by `tests/gateway/common/test_graph.py`, `tests/gateway/channels/email/test_views.py`, `tests/gateway/channels/outlook/test_views.py`, and `tests/gateway/channels/teams/test_views.py`; the primitive surface is covered in `tests/function_manager/primitives/test_scope.py`. # OAuth & secrets Source: https://docs.unify.ai/workspace/developers/oauth-and-secrets Token plumbing, split storage, and how the sandbox is kept token-free This page covers the runtime half of the OAuth story: how a running assistant obtains, stores, and exposes workspace credentials — and how raw tokens are kept away from everything the LLM can read or execute. ## The provider registry [`unify/common/runtime_oauth.py`](https://github.com/unifyai/unify/blob/main/unify/common/runtime_oauth.py) is the single source of truth for what an "OAuth provider" is at runtime. `OAuthProviderMetadata` describes each provider — canonical name, aliases, and the secret names that hold its state — and `_OAUTH_PROVIDER_METADATA` registers the two built-ins: | Provider | Aliases | Secrets | | ----------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `google` | `gmail`, `google_workspace`, `drive` | `GOOGLE_ACCESS_TOKEN`, `GOOGLE_REFRESH_TOKEN`, `GOOGLE_TOKEN_EXPIRES_AT`, `GOOGLE_GRANTED_SCOPES` | | `microsoft` | `msft`, `ms365`, `microsoft_365`, `graph` | `MICROSOFT_ACCESS_TOKEN`, `MICROSOFT_REFRESH_TOKEN`, `MICROSOFT_TOKEN_EXPIRES_AT`, `MICROSOFT_GRANTED_SCOPES` | `_resolve_oauth_provider` normalizes aliases, so actor code can say `get_oauth_access_token("drive")` and land on Google. Adding a new OAuth provider means adding a metadata entry here — the sync allowlist, proxy env, and prompt documentation all derive from it. ## Two functions, one trust boundary The module exposes two token accessors, and the difference between them is the security model: * **`get_provider_access_token(provider, min_ttl_seconds=300)`** — returns the **real** bearer token. Trusted-runtime only: used by `WorkspaceEmailManager` and by the proxy itself. If the token is missing or within the TTL window of expiry, it forces a secret sync from Orchestra first. * **`get_oauth_access_token(provider, min_ttl_seconds=300)`** — the sandbox-facing function. Returns a **proxy nonce**, never a real token. This is the symbol injected into `execute_code` globals. Supporting pieces: `get_provider_access_token_optimistic` (no pre-expiry gate — the proxy uses it per-request and retries once on 401), `refresh_provider_access_token` (force a re-sync from Orchestra — note it does **not** call the provider's token endpoint), and `get_refresh_token_oauth_env_overlay` (the env-var overlay for subprocesses). `get_oauth_prompt_context` renders the actor-facing documentation block for all of this. ## Split storage in SecretManager [`unify/secret_manager/secret_manager.py`](https://github.com/unifyai/unify/blob/main/unify/secret_manager/secret_manager.py) mirrors assistant secrets down from Orchestra (`_sync_assistant_secrets` hits the admin assistant endpoint and filters through `_resolve_secret_allowlist`, which unions the OAuth names from `refresh_token_oauth_secret_names`). The storage rule: * **Sensitive** — the raw access/refresh tokens (`_sensitive_oauth_token_names`) live only in the in-memory `_oauth_tokens` dict, read via `get_oauth_token`. They are never written to the Secrets context, `.env`, or `os.environ`. * **Non-sensitive metadata** — `*_TOKEN_EXPIRES_AT` and `*_GRANTED_SCOPES` — is mirrored to the environment so that scope checks and expiry hints work everywhere, including inside the sandbox. The same sync pass also pulls the workspace file-access policy (`_sync_workspace_file_policy` → `PolicyStore.set_policies` — see [Provider proxy](/workspace/developers/provider-proxy)). **Sync triggers.** `sync_assistant_secrets_if_stale` is a debounced gate called before every `execute_code` (from [`unify/actor/code_act_actor.py`](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py) with a 60-second TTL), forced on assistant-update events (see `unify/conversation_manager/domains/managers_utils.py`), and forced by `get_provider_access_token` when a token is missing or near expiry. The practical upshot: reconnecting an account in the Console propagates to a live assistant within a minute without a restart. **Refresh model.** The runtime never performs OAuth refresh grants. A platform job refreshes tokens against Google/Microsoft and writes them to Orchestra; the runtime's "refresh" is always *pull the newer token down*. This keeps refresh-token custody in exactly one place. ## Keeping the sandbox token-free Several mechanisms cooperate so that no raw token is observable from LLM-authored code: * **Injection without exposure** — [`unify/function_manager/execution_env.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/execution_env.py) (`create_execution_globals`) places `get_oauth_access_token` — the nonce-returning one — into sandbox globals. * **Environment scrubbing** — `provider_token_env_keys` in [`unify/provider_proxy/session.py`](https://github.com/unifyai/unify/blob/main/unify/provider_proxy/session.py) lists the sensitive keys, and `build_sandbox_env` strips them while overlaying the proxy variables (`WORKSPACE_PROXY_URL`, `WORKSPACE_PROXY_TOKEN`, `MICROSOFT_GRAPH_BASE`, `GOOGLE_DRIVE_BASE`, `GOOGLE_API_BASE`). Subprocess execution sites in [`unify/function_manager/function_manager.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/function_manager.py) and `shell_session.py` apply it; `ensure_proxy_running` additionally scrubs any lingering raw tokens from the parent `os.environ`. * **Virtual-env isolation** — functions running in dedicated venvs (`unify/function_manager/venv_runner.py`) get a `get_oauth_access_token` shim that RPCs the parent process (namespace `runtime`, handled by `FunctionManager._handle_venv_rpc`), so even a separate interpreter never receives the real token. The result: the only way LLM-authored code can use a workspace credential is through the proxy, where policy is enforced. ## Scope awareness Granted scopes are deliberately *visible* (they're metadata, not credentials). The actor prompt — `_EXTERNAL_APP_INTEGRATION` in [`unify/actor/prompt_builders.py`](https://github.com/unifyai/unify/blob/main/unify/actor/prompt_builders.py) — teaches the model a checking discipline: before calling an API, read `GOOGLE_GRANTED_SCOPES` / `MICROSOFT_GRANTED_SCOPES` (space-separated raw scope strings; Microsoft short names are prefixed with `https://graph.microsoft.com/` except `offline_access`). If the secret is present but the needed scope is absent, don't call the API — tell the user to reconnect with the extra feature from the Console. That behavioral rule is what turns a missing scope into a helpful message instead of a 403. ## Tests The behavior on this page is pinned by `tests/common/test_runtime_oauth.py`, `tests/secret_manager/test_oauth_tokens.py`, and `tests/function_manager/test_runtime_oauth_bridge.py` (the venv RPC bridge). # Provider proxy Source: https://docs.unify.ai/workspace/developers/provider-proxy The localhost choke point: full REST surface in, policy-enforced requests out [`unify/provider_proxy/`](https://github.com/unifyai/unify/tree/main/unify/provider_proxy) is a small FastAPI app bound to loopback that sits between sandboxed actor code and the real Google / Microsoft APIs. It exists to solve two problems at once: give LLM-authored code the **complete** provider REST surface without a real token, and enforce the per-assistant **file-access allowlist** at a single choke point. Provider proxy policy pipeline: a sandbox request is classified by classify.py into non_file (passthrough), file_read, file_write, or unknown_file (403 default-deny); file reads and writes go through policy evaluation in policy.py and ancestry.py, with allowed requests forwarded to the upstream API and masked items returned as 404/403 Provider proxy policy pipeline: a sandbox request is classified by classify.py into non_file (passthrough), file_read, file_write, or unknown_file (403 default-deny); file reads and writes go through policy evaluation in policy.py and ancestry.py, with allowed requests forwarded to the upstream API and masked items returned as 404/403 ## Module map | Module | Key symbols | Role | | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | [`proxy.py`](https://github.com/unifyai/unify/blob/main/unify/provider_proxy/proxy.py) | `build_app`, `ensure_proxy_running`, `_dispatch`, `_forward`, `_handle_write`, `_handle_batch` | The FastAPI app and request pipeline; one catch-all route `/{provider}/{rest_path}` | | [`session.py`](https://github.com/unifyai/unify/blob/main/unify/provider_proxy/session.py) | `ProxySession`, `sandbox_env`, `build_sandbox_env`, `provider_token_env_keys` | The nonce, the sandbox env overlay, and the scrub list | | [`classify.py`](https://github.com/unifyai/unify/blob/main/unify/provider_proxy/classify.py) | `classify`, `Classification`, `Locator` | Path-template matching → request kind | | [`policy.py`](https://github.com/unifyai/unify/blob/main/unify/provider_proxy/policy.py) | `WorkspaceFilePolicy`, `PolicyStore`, `evaluate_access`, `decision_key`, `index_decisions` | The allowlist model, mirrored from Orchestra | | [`ancestry.py`](https://github.com/unifyai/unify/blob/main/unify/provider_proxy/ancestry.py) | `is_allowed`, `ancestry_chain`, `child_allowed`, `google_get`, `ms_get`, `ms_get_by_path` | Resolving an item's parent chain against the policy | | [`filter.py`](https://github.com/unifyai/unify/blob/main/unify/provider_proxy/filter.py) | `filter_listing`, `filter_changes`, `_rewrite_pagination_links` | Masking disallowed items out of listings, search, and delta responses | | [`ENDPOINTS.md`](https://github.com/unifyai/unify/blob/main/unify/provider_proxy/ENDPOINTS.md) | — | The supported path templates, the gating matrix, and how to add patterns | Upstreams are fixed: `microsoft` → `graph.microsoft.com`, `google` → `www.googleapis.com`. ## The request pipeline 1. **Authenticate** — `_dispatch` requires `Authorization: Bearer {nonce}`, where the nonce belongs to the current `ProxySession`. The sandbox got that nonce from `get_oauth_access_token`; nothing else is accepted. 2. **Classify** — `classify(provider, method, rest_path, query)` matches the request against known path templates and returns one of: `KIND_NON_FILE` (mail, calendar, contacts, any non-drive surface), `KIND_FILE_READ`, `KIND_FILE_WRITE`, `KIND_BATCH` (Graph `$batch`, handled per-subrequest by `_handle_batch`), or `KIND_UNKNOWN` — a file-shaped path that matched nothing, which is **denied by default** with a 403. Default-deny on unknowns is what makes the endpoint catalogue in `ENDPOINTS.md` a real security boundary rather than documentation. 3. **Enforce** — file reads and writes are gated (next section); `non_file` requests pass straight through. 4. **Forward** — `_forward` injects the real token via `get_provider_access_token_optimistic`, and on a 401 forces a secret re-sync (`refresh_provider_access_token`) and retries once. Pagination links in responses are rewritten to point back through the proxy so follow-the-link code stays inside the policy boundary. ## The policy model A `WorkspaceFilePolicy` (per provider) is `default_allow` plus a tuple of `decisions`, each `{drive_id, item_id, allow}` — exactly the shape the Console's file picker saves. Evaluation, in `evaluate_access` + `ancestry.is_allowed`, walks the item's **parent chain** (fetched live from the provider via `google_get` / `ms_get`, item first, then ancestors): the **nearest explicit decision wins**, and if no ancestor carries a decision the policy's `default_allow` applies. That's the code behind the Console's "Selecting a folder grants access to everything inside it" and the "New files accessible by default" toggle. Two subtleties worth knowing: * **Gating is opt-in.** `PolicyStore.get(provider)` returning `None` (no Console policy saved) means unrestricted passthrough. The moment a policy exists, `default_allow=False` semantics kick in for anything unchecked. * **Masked means invisible, not forbidden.** Direct reads of a disallowed item return **404** (not 403) so the item's existence isn't leaked, and `filter_listing` / `filter_changes` silently drop masked items from listings, search results, and delta feeds. Writes into disallowed locations fail via `_handle_write` checking parents and destinations. The policy arrives via `SecretManager._sync_workspace_file_policy`, which mirrors Orchestra's per-assistant `workspace-file-access` config into the process-local `PolicyStore` on the same debounced cadence as secrets. ## Actor integration The proxy starts lazily: `SessionExecutor.execute` in [`unify/actor/execution/session.py`](https://github.com/unifyai/unify/blob/main/unify/actor/execution/session.py) calls `ensure_proxy_running` before any code runs. Sandbox code then uses the drop-in base URLs from the environment: ```python theme={null} token = get_oauth_access_token("microsoft") # proxy nonce, not a real token base = os.environ["MICROSOFT_GRAPH_BASE"] # …/microsoft, via the proxy resp = httpx.get( f"{base}/me/drive/root/children", headers={"Authorization": f"Bearer {token}"}, ) ``` `MICROSOFT_GRAPH_BASE`, `GOOGLE_DRIVE_BASE`, and `GOOGLE_API_BASE` are documented to the model in the `execute_code` docstring ([`unify/actor/code_act_actor.py`](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py)) and the `_EXTERNAL_APP_INTEGRATION` block in [`unify/actor/prompt_builders.py`](https://github.com/unifyai/unify/blob/main/unify/actor/prompt_builders.py). Calendar, Gmail-API, and People requests ride the same proxy but classify as `non_file`, so they pass through ungated — the file allowlist governs files only. Long-lived execution backends (venvs, shell sessions) get a fresh overlay per execution via `FunctionManager._get_runtime_oauth_env_overlay` → `get_refresh_token_oauth_env_overlay`, so a proxy restart or nonce rotation never strands them. ## Extending the endpoint catalogue To support a new provider endpoint family, add its path template to `classify.py` (and the gating matrix in `ENDPOINTS.md`). Anything file-shaped you don't add stays default-denied — the safe failure mode. The classification and filtering behavior is pinned by `tests/provider_proxy/test_classify.py`, `test_proxy_enforcement.py`, `test_filter.py`, `test_forward_auth.py`, and `test_session_env.py`. # Email & your inbox Source: https://docs.unify.ai/workspace/email Your assistant works inside the connected mailbox Once a workspace is connected, your assistant doesn't just *have* an email address — it can work **inside the mailbox**: reading what's arrived, searching years of history, summarizing what matters, and sending mail on the account's behalf. ## What your assistant can do * **Read recent mail** — "anything important in my inbox this morning?" gets you a short, prioritized summary rather than a wall of unread counts. * **Search the mailbox** — find that thread from March, the attachment a client sent, or every message from a particular sender. Full mailbox search is available, not just recent messages. * **Read full messages** — including complete bodies and threads, so its summaries and answers reflect what was actually said. * **Send and reply** — compose new mail with to/cc/bcc, or reply within an existing thread so conversations stay properly grouped. * **Draft for review** — ask for a draft reply and approve it before anything is sent. ## Whose name is on the email? It depends on the account you connected — which is why [whose account gets connected](/workspace/overview#whose-account-gets-connected) matters: * **T-W1N** is connected to *your* account, so mail it sends from your workspace comes **from you**. It's your digital twin acting in your name — ideal for drafting replies, chasing threads, and keeping your inbox under control. * **A hired assistant** has its own dedicated account, so it sends as **itself** — a named team member with its own address. That mailbox is also where it receives mail, as covered in [Communication → Email](/communication/email). ## A taste of it, right after connecting During onboarding, the first thing your assistant does with a fresh workspace connection is prove it's useful: it reads your recent mail and sends back a short summary, then offers to draft a reply to anything notable. That pattern — *summarize, then offer one helpful next step* — is how it approaches your inbox generally. Inbox triage makes a great recurring task: ask your assistant to summarize your inbox every morning at 8am, flagging anything that needs a reply. # Files Source: https://docs.unify.ai/workspace/files Drive, OneDrive, and SharePoint — with you in control of access With a workspace connected, your assistant can work with the cloud files the connected account can reach: **Google Drive** (including shared drives) on Google, **OneDrive and SharePoint** on Microsoft 365. ## What your assistant can do * **Browse and search** — find documents by name, location, or a description of what's in them. * **Read** — open documents, spreadsheets, and other files to answer questions, extract information, or summarize. * **Create and edit** — write new documents, update spreadsheets, and save deliverables into your folders. * **Organize** — rename, move, and tidy. During onboarding it may offer a gentle suggestion if your Drive looks messy — but it only tidies if you say yes. ## Controlling what it can see You decide exactly which files and folders your assistant can access, from the **File access** section of the Workspace dialog: * A tree of your drives lets you check or uncheck any file or folder. **Selecting a folder grants access to everything inside it.** * The **"New files accessible by default"** toggle sets the overall posture: * **On** — everything is accessible *except* what you've unchecked. * **Off** — *only* what you've checked is accessible. * Click **Save file access** and the rules take effect immediately. Until you set any rules, your assistant can access what the connected account can. Once rules exist, they're enforced everywhere — files outside the allowlist simply don't exist from your assistant's point of view: they don't appear in its searches or listings, and it can't read or write them. Access rules follow the connected account's own permissions. Your assistant can never see files the connected Google or Microsoft account itself couldn't open. ## Other ways to share files The workspace is for your **cloud file library**. For one-off files, you don't need it at all: * **Attach files in chat** — drop a file into [Console chat](/communication/console-chat) and your assistant works with it directly. * **Send files on any channel** — WhatsApp photos, email attachments, Slack uploads — your assistant picks them all up. A good starting posture for sensitive workspaces: turn "New files accessible by default" **off**, then check just the project folders your assistant actually works in. You can widen access any time. # Google Meet & Teams meetings Source: https://docs.unify.ai/workspace/meetings Your assistant attends meetings like any remote colleague Beyond its own [Unify Meet](/communication/unify-meet) calls, your assistant can join the video meetings the rest of your team already uses — Google Meet and Microsoft Teams. ## Inviting your assistant to a meeting Send your assistant the meeting link — paste it into chat, text it, email it, however you like — and ask it to join. It shows up in the meeting like any remote participant: it listens, speaks in its own voice, and contributes. Works with: * **Google Meet** — any standard meeting link. * **Microsoft Teams** — any Teams meeting link. Give your assistant a quick brief before the meeting ("join this call and take notes", "present the weekly numbers", "answer questions about the project status") and it handles the rest, reporting back to you afterwards. ## Your assistant can host, too With a [Microsoft 365 connection](/workspace/teams), your assistant can **create** Teams meetings: * **Instant links** — "create a meeting link for me and Sam" gets you a ready-to-share Teams link. * **Scheduled meetings** — it can put a meeting on the calendar with a Teams link and send invites to all attendees. ## During the meeting * Your assistant behaves like a considerate attendee — it speaks when spoken to or when it has something useful, and otherwise listens. * It can share its screen in the meeting when it has something to show. * One live call at a time: while it's in a meeting it won't simultaneously be on the phone. # Overview Source: https://docs.unify.ai/workspace/overview Connect Google Workspace or Microsoft 365 and unlock your assistant's day-to-day usefulness Most real work lives inside your email, calendar, and files. Connecting a **Google Workspace** or **Microsoft 365** account is how your assistant gets access to that world — reading and sending mail, checking your calendar, working with documents in Drive, OneDrive, and SharePoint, and more. It's a one-time connection, it takes about thirty seconds, and it's free — there are no credit costs for connecting or keeping a workspace connected. ## What it unlocks Read, search, summarize, and send mail from the connected mailbox. Review your schedule, flag conflicts, create and reschedule events. Work with Google Drive, OneDrive, and SharePoint — with you in control of exactly which files it can see. Look people up in your address book and manage your to-do lists. On Microsoft 365, chat, post in channels, and host meetings as the connected account. Join Google Meet and Teams meetings, and host Teams meetings on Microsoft 365. On Microsoft 365, the connection also powers [Microsoft Teams](/workspace/teams) — chats, channels, and meetings, with your assistant acting as the connected account. The workspace connection covers the Google/Microsoft suite. Other apps — your CRM, project tracker, and so on — connect separately through [Integrations](/integrations/overview). ## Whose account gets connected? This is the single most important thing to get right, and it depends on which assistant you're connecting: Connect **your own** Google or Microsoft account. T-W1N is your digital twin — when it works in your workspace it acts through your account and shows up as you. That's the point: it reads *your* inbox, checks *your* calendar, and works in *your* files, always within the permissions you grant. Create a **brand-new** Google or Microsoft account for the assistant — its own identity, like any new team member. Do **not** connect your personal account to a hired assistant; only T-W1N should have access to your personal account. The steps: 1. Log out of your own account. 2. Create a new account for the assistant, or ask your IT team to. 3. Log into the new account on your machine. 4. Pick the workspace provider in the Console to connect it. The new account becomes the assistant's own mailbox and workspace, with its own access controls managed by your organization — see [T-W1N vs hired teammates](/hiring/twin-vs-teammates) for why this matters. ## Google or Microsoft — what's the difference? Your assistant works equally well with both. The feature sets differ slightly because the ecosystems do: | Feature | Google Workspace | Microsoft 365 | | --------------------- | ---------------- | ----------------------------------- | | Email | Gmail | Outlook | | Calendar | Google Calendar | Outlook Calendar | | Files | Google Drive | OneDrive + SharePoint | | Contacts | Google Contacts | Microsoft contacts | | Tasks | Google Tasks | Microsoft To Do | | Teams chat & meetings | — | [Microsoft Teams](/workspace/teams) | Ready to connect? Head to [Connecting your workspace](/workspace/connecting). # Access & security Source: https://docs.unify.ai/workspace/security What your assistant can reach, and how you stay in control Connecting a workspace grants meaningful access, so it's worth knowing exactly what that means — and how to change your mind. ## Your assistant only gets what you approve The permissions are the ones you selected in the Workspace dialog, shown to you in plain terms on Google's or Microsoft's own consent screen before you approve. Nothing is granted silently: * Optional features (Calendar, Contacts, Tasks) are only included if you tick them. * Adding a feature later means going through the consent screen again — you re-approve the expanded permission set explicitly. * On top of the OAuth grant, [file access rules](/workspace/files) let you narrow file visibility down to specific folders. ## Where the connection lives Sign-in happens directly with Google or Microsoft — the platform never sees your password. The resulting access credentials are stored securely server-side, never in your browser, and your assistant's working environment uses them without ever exposing them: even the code your assistant writes and runs cannot read the raw credentials. ## Two ways to pull the plug You're never locked in — access can be revoked from either side, at any time: 1. **In the Console** — Workspace dialog → **Disconnect**. This revokes the platform's access, stops inbox monitoring, and deletes the stored connection. Your assistant immediately loses all workspace access. 2. **At the provider** — remove the app from **Google Account → Security → Third-party access** or **Microsoft Account → Apps & permissions**. This kills access at the source, regardless of anything on the platform side. ## Boundaries worth knowing * **One mailbox, one assistant.** A workspace account can only be connected to one assistant at a time. * **The account's permissions are the ceiling.** Your assistant can never reach anything the connected account itself can't — org-level sharing rules, SharePoint permissions, and drive ACLs all still apply. * **T-W1N acts as you** on your connected account; hired assistants act as **themselves** on their own accounts. If you're uneasy about an assistant having some access, that's the knob to reach for: hired assistants get their own account whose access your organization controls like any employee's. # Microsoft Teams Source: https://docs.unify.ai/workspace/teams Your assistant acts in Teams as its own connected Microsoft account When you connect a **Microsoft 365** account for your assistant, it can work in Microsoft Teams as *that account* — chatting, posting in channels, and hosting meetings under its own Microsoft identity, the same way it works in the [connected mailbox](/workspace/email). This is the account-bound path. It's set up per assistant as part of the [workspace connection](/workspace/connecting): pick **Microsoft 365** and the **Teams** feature is included, so Teams is enabled the moment the account is linked. It's distinct from the org-wide [Unify Teams app](/communication/microsoft-teams), where the assistant appears as a shared bot rather than as a Microsoft user. ## Messaging * **1:1 chat** — message your assistant in Teams like any colleague, and it replies as its connected account. * **Group chats** — it can take part in group conversations with two or more people, and can start them too. * **Channels** — @mention your assistant in a channel and it replies in the thread; it can also post to channels and even create new ones. * **Attachments** — it can send files in Teams messages. ## Hosting meetings Because it acts as a real Microsoft account, your assistant can **create** Teams meetings, not just attend them: * **Instant links** — "create a meeting link for me and Sam" gets you a ready-to-share Teams link on the spot. * **Scheduled meetings** — it can put a Teams meeting on the calendar with a link and send invites to all attendees. Joining a meeting is separate and doesn't require this connection — send any Teams meeting link and your assistant joins with its voice. See [Meetings](/workspace/meetings). ## Whose name is on it? Just like [email](/workspace/email#whose-name-is-on-the-email), it depends on the account you connected — see [whose account gets connected](/workspace/overview#whose-account-gets-connected): * **T-W1N** is connected to *your* account, so it chats and hosts meetings **as you** — your digital twin acting in your name. * **A hired assistant** has its own dedicated account, so it shows up as **itself** — a named team member with its own Teams presence. If you'd rather everyone in the org reach your assistants through one shared bot — including people who never see the assistant's Microsoft account — use the org-installed [Unify Teams app](/communication/microsoft-teams) instead. You can use either path, or both. # Control & safety Source: https://docs.unify.ai/your-computer/control-and-safety How your assistant behaves on your machine — and how you stay in charge Giving an assistant control of your own computer only works if its conduct is predictable. These are the rules it operates under — and the levers you hold. ## When it acts on your machine * **Only when you ask.** A linked machine is not a playground. Your assistant touches it only when you clearly ask for something on *your* computer — "open the file on my laptop", "click submit on my screen". For everything else it uses [its own machine](/their-computer/overview). * **Your live screen share comes first.** If you're on a [Meet call](/communication/unify-meet) sharing your screen, that's where it follows your machine — watching and guiding. Direct control of the linked machine is for when there's no live share running. * **It asks when unsure.** Ambiguous request? It asks a brief clarifying question rather than guessing on your hardware. ## How it behaves while working * **Consequential actions get confirmed.** Before anything destructive, irreversible, or that sends, deletes, or purchases on your behalf, it states what it's about to do and waits for a clear go-ahead. * **It narrates at a sensible pace** — progress at the level of the job, not a play-by-play of every click. * **It never modifies your machine to work around a problem.** If something on your computer errors, it reports rather than "fixing" your setup — installing or changing anything requires your explicit ask. * **It works around your lock screen honestly.** If your Mac is locked and you've [saved your login password](/your-computer/setup) as an encrypted secret, it unlocks and proceeds; otherwise it tells you it's blocked. ## Your controls Everything is revocable, at several levels, and revocation is fast: | Lever | Effect | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | **Revoke control mid-session** | The assistant stops instantly — every action re-checks your consent, so a revocation takes effect immediately, not at the next task | | **Filesystem access toggle** | File access off within about a minute, keys cleared | | **Unlink** | This assistant loses the machine; other links unaffected | | **Stop Services** (tray) | The machine goes dark to the platform until you start it again | | **Delete desktop** | Unregistered everywhere, secure tunnel torn down | | **Uninstall** | Everything above, plus the software removed | ## How the connection works (the short version) * Your machine makes an **outbound connection to Unify's secure relay** — nothing on your computer is opened to inbound traffic from the internet. * Control and file access run through that tunnel, authenticated with your account's credentials; file access additionally uses per-link keys that exist only while the toggle is on. * Your saved macOS password (if any) is stored as an **encrypted secret**, used only on the Mac you linked. * **Your machine is never shown in the Console live view** — the desktop you can watch during Meet calls is always the [assistant's own](/their-computer/watching-and-control), never yours. For the full picture of how the app relates to screen sharing, see [How it works](/your-computer/how-it-works). ## Shared assistants, private machines Links are personal. If a shared team assistant works with several people, **each person links their own machine**, and the assistant acts on yours only for you. Nobody else's requests reach your computer, and your colleagues' machines are equally invisible to you. There's no extra charge for any of this — linked desktops carry no separate fee, and work your assistant does on your machine consumes normal usage credits like anything else. # Linked desktop internals Source: https://docs.unify.ai/your-computer/developers How remote control of the user's own machine actually works — a code-level tour of the open-source runtime Everything else in this section describes the linked desktop as a user experiences it. This page is for developers: how the open-source [`unifyai/unify`](https://github.com/unifyai/unify) runtime models, controls, and gates access to a **user's own machine** — as opposed to the assistant's managed VM. The closed-source pieces (the Console linker UI, the tunnel relay, and the desktop client app itself) appear here only as opaque endpoints; every symbol referenced below is in the public repo. The mental model to hold onto: the runtime knows about **three machines**, each with its own primitives namespace and execution surface: | Machine | Primitives namespace | Execution surface | Transport | | ------------------------- | -------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------ | | The runtime host itself | — (local `execute_code`) | `local` | in-process | | Assistant's managed VM | `primitives.computer.desktop` / `.web` | `assistant_desktop` | agent-service at `SESSION_DETAILS.assistant.desktop_url`, plus continuous workspace bisync | | **User's linked machine** | `primitives.computer.user_desktop` | `user_desktop` | agent-service at the link's tunnel URL, plus **on-demand** SFTP | The user's machine is deliberately the odd one out: it is never shown in the Console live view, every call re-checks consent, file access is pull-on-request rather than continuously synced, and writebacks never touch originals. Architecture of linked user-desktop control: ConversationManager dispatches act(...) to CodeActActor, which reaches primitives.computer.user_desktop; _UserDesktopFactory resolves a UserDesktopLink from SESSION_DETAILS and hands back a UserDesktopHandle whose calls travel over an outbound-only tunnel to the user's machine, where an agent-service, a live desktop view for assistant perception, and a key-gated SFTP file channel run Architecture of linked user-desktop control: ConversationManager dispatches act(...) to CodeActActor, which reaches primitives.computer.user_desktop; _UserDesktopFactory resolves a UserDesktopLink from SESSION_DETAILS and hands back a UserDesktopHandle whose calls travel over an outbound-only tunnel to the user's machine, where an agent-service, a live desktop view for assistant perception, and a key-gated SFTP file channel run ## The link: session state The unit of state is `UserDesktopLink` in [`unify/session_details.py`](https://github.com/unifyai/unify/blob/main/unify/session_details.py): | Field | Semantics | | --------------------------------------- | ---------------------------------------------------------------------------------------- | | `owner_user_id` | The user who owns the machine — and the key of the link map | | `url` | Reverse-tunnel URL of the device's agent-service (screen + exec control) | | `os` | `"ubuntu"`, `"windows"`, or `"macos"` | | `filesys_sync` | Standing Console consent for home-filesystem access | | `sftp_tunnel_host` / `sftp_tunnel_port` | Raw-TCP SFTP tunnel coordinates, present once the device has registered its file channel | | `filesys_available` (property) | `filesys_sync` **and** both tunnel coordinates — "consented *and* actually reachable" | Links live on the session as `AssistantDetails.user_desktops`, a `dict[str, UserDesktopLink]` keyed by owner, with `AssistantDetails.user_desktop_for(user_id)` as the lookup. The docstring carries the important design point: > A shared assistant can be linked to a different desktop per user, so links > are keyed by `owner_user_id` and resolved at runtime against whoever is > currently interacting with the assistant. That's the multi-user story in one line: for a shared assistant, the ConversationManager resolves an **acting user** each turn (the message sender when it maps to a provisioned system user, else the workspace owner) and desktop targeting follows the *speaker's* link, not the owner's. Three mechanics worth knowing: * **Wire → session.** Links arrive as a `user_desktops` list on `StartupEvent` and `AssistantUpdateEvent` ([`unify/conversation_manager/events.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/events.py)), applied via `SessionDetails.populate` and normalized by `normalize_user_desktops`. The gateway includes the field when building assistant payloads in [`unify/gateway/adapters/common.py`](https://github.com/unifyai/unify/blob/main/unify/gateway/adapters/common.py). Link/unlink churn is just an `AssistantUpdateEvent` refresh — there is no dedicated "desktop linked" event. * **Subprocess propagation.** `SessionDetails.export_to_env` round-trips the map through the `ASSISTANT_USER_DESKTOPS` env var (`_encode_user_desktops` / `_decode_user_desktops`) so actor subprocesses see the same links. * **No keys on the link.** SFTP private keys never ride `UserDesktopLink`; they're fetched on demand from an admin-only field (see the files section below). ## The control surface: `primitives.computer.user_desktop` The namespace is built in [`unify/function_manager/primitives/runtime.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/runtime.py) around three types: * **`_UserDesktopFactory`** — the object bound at `primitives.computer.user_desktop`. Two synchronous entry points: `list_linked()` returns one dict per linked machine (`user_id`, `os`, `filesys_sync`, `filesys_available`), and `session(user_id=None)` returns a handle. Resolution (`_resolve_user_desktop_link`) defaults to the session's primary user, falls back to the sole link when exactly one exists, and raises when several users have linked desktops and no `user_id` was given — ambiguity is an error, not a guess. * **`UserDesktopHandle`** — the per-user control handle. Its method set is `_DESKTOP_METHODS`: the full computer vocabulary (`act`, `observe`, `query`, `navigate`, `get_links`, `get_screenshot`, plus low-level input like `click`, `type_text`, `press_key`, `scroll`, `drag`, `execute_actions`) **minus** `get_content` and `solve_captcha`. All methods are async; `user_id`, `os`, and `label` are properties. * **`ComputerPrimitives`** — the owner that holds caches and consent state (below). Routing is thin by design. Each handle method resolves the link, asserts consent, then obtains a backend session: * The backend is the same `MagnitudeBackend` / `ComputerSession` HTTP client used for the assistant's VM ([`unify/function_manager/computer_backends.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/computer_backends.py)), pointed at `{scheme}://{netloc}/api` parsed from `UserDesktopLink.url` — i.e. the device's agent-service reached through its outbound tunnel. Requests authenticate with `Bearer SESSION_DETAILS.unify_key`, which the device verifies against the platform before acting. * Backends are cached per tunnel URL in `ComputerPrimitives._user_desktop_backends` — separate from the assistant VM's singleton backend. Three deliberate differences from the assistant-desktop path: 1. **No VM-readiness gate.** Assistant desktop calls wait on the managed VM being ready; user-desktop calls don't — the machine either answers through its tunnel or errors. 2. **Consent is re-checked per call**, not per session (`_assert_user_desktop_allowed`) — a mid-session revocation fails the very next method call with `PermissionError`, whose message is the constant `_USER_DESKTOP_REVOKED_MSG`: stop immediately, don't retry. 3. **Failure clears caches.** Terminal transport errors route through `_handle_user_desktop_error`, which drops the cached backend and SFTP clients so the next attempt reconnects cleanly. ## The file channel: `user_desktop.files` File flow between the assistant runtime and the user's home directory: the files namespace calls UserHomeSFTP, list browses metadata only, pull and sync stage copies into the read-only mirror at ~/Unity/Remote/<user_id>/, and push writes a new timestamped copy into the remote /.unity-edits/ tree, never touching originals; noise and credential directories are excluded File flow between the assistant runtime and the user's home directory: the files namespace calls UserHomeSFTP, list browses metadata only, pull and sync stage copies into the read-only mirror at ~/Unity/Remote/<user_id>/, and push writes a new timestamped copy into the remote /.unity-edits/ tree, never touching originals; noise and credential directories are excluded File access is a **separate channel with separate consent**, implemented in [`unify/file_manager/sync/user_sftp.py`](https://github.com/unifyai/unify/blob/main/unify/file_manager/sync/user_sftp.py) and exposed through `_UserDesktopFilesNamespace` (in [`runtime.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/runtime.py)) as four async methods: | Method | Contract | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `files.list(path, user_id)` | Browse a home-relative directory — metadata only, nothing copied | | `files.pull(path, user_id)` | Fetch one file into the local staging mirror | | `files.sync(path, user_id)` | Bulk-mirror a subtree (`""` = entire home — discouraged; `list` + `pull` is the canonical lazy path) | | `files.push(local_path, dest_path, user_id)` | Write content back **as a timestamped copy** — never over the original | `UserHomeSFTP` is the transport: an on-demand rclone SFTP client, one per user, connecting as the fixed user `SFTP_USER = "unity"` to the link's `sftp_tunnel_host:port`, serving the user's `$HOME` as its root. Its `setup()` fetches the per-link private key, writes it plus a temporary rclone config, and connection-tests before first use. Key custody is the notable design decision, straight from the module docstring: the private key lives server-side in an admin/runtime-only map (`user_desktop_filesync_keys`, keyed by `owner_user_id`) and is fetched by `_get_private_key()` at setup time — *"so it never rides"* the link payload that circulates through session state. Three properties make this channel safe to expose to an autonomous agent: * **A read-only mirror, not a live mount.** Reads stage into `~/Unity/Remote//` (the `local_root` property), mirroring the remote tree. The actor's filesystem-context prompt (`_build_filesystem_context` in [`unify/actor/prompt_builders.py`](https://github.com/unifyai/unify/blob/main/unify/actor/prompt_builders.py)) names this mirror, and `LocalFileSystemAdapter` ([`unify/file_manager/filesystem_adapters/local_adapter.py`](https://github.com/unifyai/unify/blob/main/unify/file_manager/filesystem_adapters/local_adapter.py)) accepts absolute paths, so staged files flow through the normal `primitives.files.*` machinery without special-casing. * **Writebacks are versioned copies.** `push` computes a remote path under `EDITS_DIR = ".unity-edits"` mirroring the destination's parent, with a `_utc_stamp()` suffix in the filename. Originals are structurally unreachable by the write path — the guarantee the Console copy makes ("your originals are never overwritten") is enforced here, not by convention. * **Tiered excludes.** `_build_excludes` composes three tiers: `_ALWAYS_EXCLUDES` (the `.unity-edits` tree itself), `_NOISE_EXCLUDES` (caches, `node_modules`, `.git`, platform junk), and `_SECRET_EXCLUDES` (`.ssh`, `.gnupg`, `.aws`, credential stores). `list_dir` applies only tier 1 — browsing stays truthful — while `pull`/`sync` apply all three, so credential material never lands in the mirror. Every file operation also publishes a `UserDesktopFileAccess` audit event (payload model in [`unify/events/types/desktop_primitive.py`](https://github.com/unifyai/unify/blob/main/unify/events/types/desktop_primitive.py)). ## Shell and Python: the `user_desktop` execution surface Command execution rides the actor's execution-surface abstraction rather than the primitives handle: * [`unify/actor/execution/surface.py`](https://github.com/unifyai/unify/blob/main/unify/actor/execution/surface.py) defines `ExecutionSurface` (`LOCAL`, `ASSISTANT_DESKTOP`, `USER_DESKTOP`) and `SurfaceCapabilities`. `_user_desktop_capabilities` grants `can_python` and `can_shell` when the link has a `url`, and `can_files` only when `filesys_available`. * The actor's `execute_code` tool ([`unify/actor/code_act_actor.py`](https://github.com/unifyai/unify/blob/main/unify/actor/code_act_actor.py)) takes `surface="user_desktop"` plus an optional `user_id`, resolved through `get_target(...)` in [`unify/actor/execution/targets/factory.py`](https://github.com/unifyai/unify/blob/main/unify/actor/execution/targets/factory.py). Remote surfaces are **stateless-only** — no persistent sessions or venvs, unlike local execution. * [`UserDesktopTarget`](https://github.com/unifyai/unify/blob/main/unify/actor/execution/targets/user_desktop.py) does the work: `run_shell` and `run_python` POST to the device agent-service's `/api/exec` via `AgentServiceExecClient` ([`exec_client.py`](https://github.com/unifyai/unify/blob/main/unify/actor/execution/targets/exec_client.py)), with Python inlined through the same `_inline_python_command` helper the assistant-desktop target uses. `put_file` / `get_file` route through `UserHomeSFTP.push` / `pull` — file movement always goes through the guarded channel, never through shell redirection. The prompt layer enforces a matching policy split: shell on `surface="user_desktop"` is **only** for commands the user explicitly wants run on their machine. Retrieving file *content* by shelling out — `cat`/`find`/`tar`/`base64`/`cp`/`scp`/`rclone` — is expressly forbidden; the actor is steered to `user_desktop.files`, which is where the no-overwrite and exclusion guarantees live. That rule appears both in the actor prompt ([`unify/actor/prompt_builders.py`](https://github.com/unifyai/unify/blob/main/unify/actor/prompt_builders.py)) and the computer environment context ([`unify/actor/environments/computer.py`](https://github.com/unifyai/unify/blob/main/unify/actor/environments/computer.py)). ## Consent: three gates, checked at three times Three consent layers for the linked desktop: standing config from the Console (UserDesktopLink.url for control, filesys_sync plus tunnel coordinates for files), live in-memory revoke sets (_user_desktop_revoked and _user_filesys_revoked, driven by UserFilesysAccessStarted/Stopped comms events), and per-call assertions in UserDesktopHandle and the files namespace that raise PermissionError on revoke Three consent layers for the linked desktop: standing config from the Console (UserDesktopLink.url for control, filesys_sync plus tunnel coordinates for files), live in-memory revoke sets (_user_desktop_revoked and _user_filesys_revoked, driven by UserFilesysAccessStarted/Stopped comms events), and per-call assertions in UserDesktopHandle and the files namespace that raise PermissionError on revoke Access control is layered, and each layer answers a different question: 1. **Standing configuration** — *has the user set this up?* The link's existence (with `url`) gates control; `filesys_available` gates files. This layer is refreshed by `AssistantUpdateEvent` whenever the user links, unlinks, or toggles filesystem access in the Console. 2. **Live session consent** — *has the user changed their mind right now?* `ComputerPrimitives` holds two in-memory revoke sets, `_user_desktop_revoked` and `_user_filesys_revoked`, mutated by the grant/revoke pairs (`grant_user_desktop_control` / `revoke_user_desktop_control` and the filesystem equivalents). The filesystem pair is wired to comms events: `UserFilesysAccessStarted` / `UserFilesysAccessStopped` ([`events.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/events.py)) arrive from the platform and are handled in [`unify/conversation_manager/domains/event_handlers.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/event_handlers.py), which also snapshots recent conversation context so a running action is *interjected* with the revocation rather than discovering it by crash. 3. **Per-call assertion** — *is this specific action still allowed?* `_assert_user_desktop_allowed` / `_assert_user_filesys_allowed` run inside every handle method and every `files.*` call, raising `PermissionError` with an unambiguous stop instruction. One nuance for extenders: the live revoke sets are enforced on the **primitives path**. `UserDesktopTarget` (the `execute_code` surface) checks standing capabilities at target-build time but does not re-assert the in-memory sets per command — if you add new execution paths, route consent through the `ComputerPrimitives` assertions to inherit mid-session revocation. A disambiguation that trips up most readers of this code: `UserRemoteControlStarted` / `UserRemoteControlStopped` are the **inverse feature** — the user taking mouse-and-keyboard control of the *assistant's* VM during a Meet call (handled via `ComputerPrimitives.set_user_remote_control`). They have nothing to do with the linked user desktop despite the similar names. ## The prompt layer: what the assistant is told Behavior users observe ("only on explicit request", "screen share first") is implemented as prompt construction: * `_build_user_machine_access_block` in [`unify/conversation_manager/prompt_builders.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/prompt_builders.py) renders the *"Seeing and controlling the user's machine"* section with the strict resolution order — (1) active screen share/webcam, (2) linked desktop, (3) neither → offer a share — plus files clauses that vary with `filesys_available` vs. merely `filesys_sync` (consented but device not yet connected: the assistant is told to say so, not to attempt the sync). * `build_system_prompt` threads `has_linked_user_desktop`, `user_filesys_consented`, `user_filesys_available`, and `acting_user_id` down from the brain ([`unify/conversation_manager/domains/brain.py`](https://github.com/unifyai/unify/blob/main/unify/conversation_manager/domains/brain.py)), so the whole block is per-turn and per-speaker. * `ComputerEnvironment.get_prompt_context` in [`unify/actor/environments/computer.py`](https://github.com/unifyai/unify/blob/main/unify/actor/environments/computer.py) gives the actor the *"Your Desktop vs. a User's Desktop"* rules of engagement: explicit request only, clarify when unsure, confirm before consequential actions, respect `PermissionError` as final, never modify their machine to work around an error — plus the macOS unlock flow using the stored login-password secret. * `console_ui.desktop_access_faq` in [`unify/common/console_ui.py`](https://github.com/unifyai/unify/blob/main/unify/common/console_ui.py) swaps the user-facing FAQ answer depending on whether a linked desktop exists. ## Where to look next The tests are the most instructive specification of the contract: | Behavior | Test file | | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Prompt blocks, acting-user resolution, `user_desktop_for` | [`tests/conversation_manager/core/test_prompt_builders.py`](https://github.com/unifyai/unify/blob/main/tests/conversation_manager/core/test_prompt_builders.py) | | Surface resolution and `execute_code` gating | [`tests/actor/code_act/test_execution_surfaces.py`](https://github.com/unifyai/unify/blob/main/tests/actor/code_act/test_execution_surfaces.py) | | SFTP exclude tiers and sync args | [`tests/file_manager/sync/test_user_sftp_excludes.py`](https://github.com/unifyai/unify/blob/main/tests/file_manager/sync/test_user_sftp_excludes.py) | | Live filesystem consent events | [`tests/conversation_manager/core/test_event_handlers.py`](https://github.com/unifyai/unify/blob/main/tests/conversation_manager/core/test_event_handlers.py) | | Comms routing of filesys events | [`tests/conversation_manager/core/test_comms_manager.py`](https://github.com/unifyai/unify/blob/main/tests/conversation_manager/core/test_comms_manager.py) | And the common extension points: * **New control verbs** — add to `_COMPUTER_METHODS` / `_DESKTOP_METHODS` in [`runtime.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/primitives/runtime.py) and implement on the backend contract in [`computer_backends.py`](https://github.com/unifyai/unify/blob/main/unify/function_manager/computer_backends.py); the handle picks them up mechanically. * **New exclusion rules** — extend the tier tuples in [`user_sftp.py`](https://github.com/unifyai/unify/blob/main/unify/file_manager/sync/user_sftp.py); keep the tier split (truthful `list`, lean `pull`/`sync`) intact. * **New execution paths** — implement an `ExecutionTarget` ([`targets/base.py`](https://github.com/unifyai/unify/blob/main/unify/actor/execution/targets/base.py)) and register it in the factory; remember the consent note above. # Files & terminal Source: https://docs.unify.ai/your-computer/files-and-terminal Your home folder and shell, on your terms Beyond seeing and clicking, a linked machine can grant two deeper capabilities — each with its own guardrails. ## Filesystem access On the linked machine's card in the Console you'll find the **Filesystem access** toggle. The disclosure next to it says exactly what it does: > Lets this assistant read files from your home folder on request and save > edited copies back — your originals are never overwritten. This exposes > your entire home directory over a secure connection; turn it off any time > to revoke access. With it enabled: * **Reading is on-request.** Your assistant doesn't crawl your disk. When you ask it to work with a file — *"grab the Q3 contract from my Documents"* — it browses to it and fetches that file over the secure channel. * **Your originals are sacred.** When your assistant edits something, the result is saved back **as a timestamped copy alongside the original**, in a review location — never over the file you had. You look at the edited copy and decide what to do with it. * **Sensitive corners are skipped.** Credential folders (`.ssh` keys and the like) and machine noise (caches, dependency folders) are excluded from what the assistant fetches. * **Off means off.** Flip the toggle any time; access is revoked and the keys that enabled it are cleared — taking effect within about a minute, even mid-session. Filesystem access pairs beautifully with everything else: a file from your laptop can be summarized in chat, become a [data table](/canvas/data), or be updated and emailed on — without you ever moving it anywhere first. ## Terminal & shell commands Your assistant can also run **commands on your machine** — the newest addition to linked desktops: * *"Run the backup script in my projects folder."* * *"Check whether Docker is running and restart it if not."* * *"Show me what's eating disk space in my home directory."* Two firm rules govern this: 1. **Only commands you ask for.** The shell is for things you explicitly want executed on your machine. Your assistant won't run commands there on its own initiative for its own convenience. 2. **The shell is not a back door to your files.** Reading and fetching your files always goes through the filesystem access channel above, with its no-overwrite guarantees and exclusions — your assistant is expressly forbidden from using shell commands to dump or copy your file contents around them. ## Whose rules apply where A quick contrast with [the assistant's own computer](/their-computer/overview), where none of this ceremony exists — it installs software, moves files, and runs commands freely on its own machine. On *your* machine the defaults invert: everything is on-request, originals are protected, and consent is checked continuously. Same assistant, very different manners — by design. # How it works Source: https://docs.unify.ai/your-computer/how-it-works What the app runs, and how it relates to screen sharing The companion app and Unify Meet's screen sharing both involve "the assistant and your screen", so it's worth being precise about what each one actually is — they're completely different mechanisms that complement each other. ## What the app runs on your machine Once installed, the app is a tray icon plus a set of quiet background services: * **A control channel** — the surface your assistant drives when you ask it to act on your machine: a live view of your desktop for its own perception, mouse and keyboard input, browser automation, and command execution. * **A file channel** — the separate, key-guarded service behind [filesystem access](/your-computer/files-and-terminal); it only accepts connections while the toggle is on. * **The tray app** — your local switchboard: status colors, Start/Stop Services, settings, logs, uninstall. None of this listens for connections from the internet. On startup the app dials **outbound** to Unify's secure relay and holds that connection open; everything the assistant does on your machine travels back down the tunnel your machine initiated. Stop the services and the tunnel closes — your machine simply goes dark to the platform. One consequence worth knowing: the live view of your desktop exists **for the assistant's eyes only**. It is never displayed anywhere in the Console — not to you, not to anyone. ## Screen sharing is a different thing entirely On a [Unify Meet](/communication/unify-meet) call, **Share your screen** is ordinary browser screen sharing — the same picker you'd get in any video call, showing whatever screen or window you choose on the computer you're calling from. That's usually your own machine, so yes: Meet screen share *does* show your computer to your assistant. But it's a fundamentally different capability from the local app: | | **Share your screen** (Meet) | **Linked desktop** (local app) | | ------------------------ | -------------------------------------- | ------------------------------------ | | What it is | A live video feed of your screen | A control connection to your machine | | Needs the app installed? | No — works for everyone | Yes | | Assistant can see | What you're showing, while you show it | Your screen, on request | | Assistant can act | **Never** — watch and guide only | Yes — click, type, run, fetch files | | Who's shown what | Both of you see the shared screen | Nothing is displayed in the Console | And to complete the picture: the desktop viewer *inside* a Meet call — **Show assistant screen** — always shows the [assistant's own machine](/their-computer/watching-and-control), never yours. There is no Console window onto your linked desktop, by design. ## How the assistant combines them When you ask about or point at *your* screen, the assistant resolves it in a strict order: 1. **An active screen share wins.** If you're sharing on a live call, that's how it sees your screen — and if you're collaborating live without a share, it offers one: *"Want to share your screen? I'll see it right away."* 2. **No share running? The linked desktop.** With the app installed, it can look at and act on your machine directly through the tunnel — taking a screenshot to answer "what's on my screen?", or doing the thing you asked. 3. **Neither available?** It says so plainly and offers a screen share — it never claims to see or control your machine when it can't. ## Watching it work on your machine So how do you supervise the assistant when it's driving your linked desktop? Two ways, both natural: * **Just watch your monitor.** It's your machine — when the assistant works on it, the cursor moves and windows open right in front of you, TeamViewer-style. You're never watching through a viewer; you're watching the real thing. * **Share your screen on a call.** If you want to narrate together, hop on a Meet call and share the screen while the assistant works on the linked machine — you both see the same thing and can talk through it live. # Overview Source: https://docs.unify.ai/your-computer/overview Let your assistant work directly on your own machine Out of the box, your assistant works on [its own computer](/their-computer/overview), and the closest it gets to yours is watching your screen share and talking you through the steps. **Linking your computer** changes that: install a small companion app on your Mac, Windows, or Ubuntu machine, link it to your assistant, and it can now see and operate *your* computer directly — apps, files, and logged-in sessions. Think of it as TeamViewer, but the remote operator is your assistant: * *"Open the report on my laptop and fix the formatting."* * *"My VPN app keeps failing — take a look at it."* * *"Tidy up my Downloads folder."* * *"Pull the contract from my Documents, update the dates, and email it out."* ## Trust first This is the most powerful access you can grant an assistant, and the Console says so plainly when you link: > Linking a desktop lets this assistant see and control that machine — its > apps, files, and logged-in sessions — during local desktop sessions. > **Only link a computer you're comfortable giving full control of.** The feature is built around that sentence. Everything is opt-in and separately revocable, the assistant touches your machine **only when you explicitly ask it to**, and [safety rules](/your-computer/control-and-safety) govern every action. But the starting point is a machine you trust it with. ## What linking enables Your assistant operates your screen — opening apps, clicking, typing — when you ask it to work on your machine. With the optional filesystem toggle, it reads files from your home folder on request and saves edited copies back — originals never overwritten. It can run shell commands on your machine — strictly the ones you ask for. macOS, Windows, and Ubuntu — one small app, a few minutes of setup. ## The three ways assistants relate to computers It's worth keeping the full picture straight: | | What it is | Who controls it | | ---------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------- | | [**Their computer**](/their-computer/overview) | The assistant's own Ubuntu/Windows desktop | The assistant — and you, during a Meet call | | **Screen share** | You showing your screen on a [Unify Meet](/communication/unify-meet) call | Only you — the assistant sees and guides, never clicks | | **Your computer (linked)** | Your own machine with the companion app installed | The assistant, when you ask — with your consent enforced live | The three compose naturally: if you're on a call sharing your screen, the assistant follows along there; when there's no live share, it can work on your linked machine directly — the full interplay is spelled out in [How it works](/your-computer/how-it-works). In the assistant's own words: > Yes — you've linked a desktop to me, so I can work directly on it. (When > there's no active screen share I drive the linked machine; if you'd > rather keep an eye on things live, just share your screen on a call.) Ready to set it up? Head to [Installing & linking](/your-computer/setup). # Installing & linking Source: https://docs.unify.ai/your-computer/setup The companion app, in a few minutes, on macOS, Windows, or Ubuntu Linking your computer takes two things: the **Unify desktop app** installed on your machine, and a **link** between that machine and your assistant in the Console. ## Where it starts Open your assistant's profile and find the **Desktop** section (it reads *"No desktop connected yet"* until you link one). Clicking it opens the **Link User Desktop** dialog, which contains everything: setup instructions, your API key, the registered-machines list, and the link controls. ## Step 1 — Install the app In **Local Setup Instructions**, choose **macOS**, **Windows**, or **Ubuntu**, then use **View setup instructions** and the download button — a `.pkg` for Mac, an `.exe` installer for Windows, a `.deb` for Ubuntu. Install like any app. During setup you'll be asked for your **Unify API Key** — use **Copy API Key** in the Console dialog and paste it in. (You can skip this and add the key later from the app's settings.) On **macOS**, approve the Screen Sharing permission prompt so the desktop can be viewed remotely. First-time setup downloads its dependencies, which can take several minutes. Setup is complete when the Unify icon appears in your menu bar or system tray and turns **green** — that means all services are running. Yellow means partially running, red means stopped. The tray menu is your local control panel: **Start/Stop Services**, **Settings…**, **View Logs…**, and **Uninstall…** all live there. ### macOS: the optional login password For Macs, the Console offers a **Save User Password** step. It's optional but useful — as the tooltip explains: > Used to grant accessibility permission and unlock your Mac when needed. > Stored as an encrypted secret, only used on the Mac you link. With it saved, your assistant can get past the lock screen when you've asked it to work while you're away. Without it, work pauses whenever the Mac locks. ## Step 2 — Link the machine to your assistant Once the app is running, your machine registers itself and appears in the **Link User Desktop** dialog. Select it to link. That's it — your assistant now has a machine of yours it can work on. A few things you'll notice in the list: * **One machine, many assistants.** The same computer can be linked to several of your assistants — the list shows *"Also linked to N other assistants"* where that's the case. * **One link per assistant.** Each assistant gets at most one of your machines; linking a different one replaces the previous link. * **Filesystem access** is a separate toggle on the linked machine — see [Files & terminal](/your-computer/files-and-terminal). ## Managing your machines From the same dialog you can: * **Rename** a desktop, so "Julia's MacBook" isn't "mac-host-2381". * **Unlink** — disconnects this assistant from the machine; the registration and other assistants' links stay. * **Delete** — removes the machine entirely. The confirmation spells out the consequences: *"This unregisters the desktop, removes it from every assistant it's linked to, and tears down its secure tunnel. The desktop app will need to be set up again to reconnect. This cannot be undone."* Uninstalling the app from the machine itself (tray → **Uninstall…**) does the same cleanup from that end: stops all services, unregisters the device, and removes the software. ## Updating New versions ship as fresh installers — download and run the latest over your existing install. Your API key and connection settings are preserved across upgrades.