The handbook
TenantsUsers and rolesApplicationsScopesAgentsContext (graphium)CultivatorsChatsTracingAudit log
Concepts

Concepts: Cultivators

Cultivators are AcasoOS's autonomous reflectors: code-defined units that watch the ingest stream and propose new context into graphium memory. Where a person curates context by hand on the Context page, a Cultivator does it continuously and unattended.

The console exposes them in the Cultivators page.

Cultivator, Pipeline, or Agent?

Three things react to data in AcasoOS, and they are easy to confuse. Hold the distinction firmly.

UnitTriggerWritesOn failure
PipelineAn ingest eventTyped individuals and links in the knowledge graph (domain graphs)Fails the event (retry / DLQ)
CultivatorThe same ingest eventMemory statements in graphium (visibility-encoded KG graphs)Best-effort; the event still succeeds
AgentA user requestNothing on its own; it answers and calls toolsSurfaces an error to the user

A Cultivator and any Pipelines fire on the same event and run in parallel; neither blocks the other. A Cultivator never writes to a table directly. It proposes statements, and the framework commits the ones that pass validation.

Kinds and bindings

The shape mirrors agents: a code-declared template, plus per-tenant configuration.

  • Kindthe template, declared in code and reviewed by acaso engineering. It carries a name, a version, the event pairs it accepts, two scope ceilings, a default_model, and — for an investigator Kind — a code-declared list of the external connections it consumes (requires_connections, covered below). Default Kinds are available to every tenant; a bespoke Kind is built for one tenant and invisible to the rest.
  • Bindingyour tenant's opt-in and configuration for a Kind, held in tenant_cultivators. It carries the enabled flag, an optional model_override and prompt_append, a pinned kind_version, and the two scope overrides. It carries no MCP integrations — external access is a tenant-level Connection (covered below), not a per-binding knob.

A Kind is in one of three states for your tenant:

  • Uninstalledno binding exists.
  • Disableda binding exists with enabled false. Configuration is kept; dispatch is stopped.
  • Enabledthe Kind dispatches on matching events.

Note: A new binding starts disabled, so configuring a Kind never silently starts writing memory. Enabling it is a deliberate flip of its own switch.

How a run happens

There is no scheduler. Cultivators are purely event-driven off the ingest fan-out:

  1. An ingest event arrives. The worker matches it against every enabled binding whose Kind accepts the event's (source_type, data_type) pair.
  2. A matched Kind's should_process(event) predicate decides whether this specific event is worth the work. A false records a skipped run and stops there.
  3. Otherwise the framework opens a run, binds the Kind's read scopes, and executes its reflective workflow: fetch context, reflect (an LLM call that may use tools), and propose statements.
  4. Each proposal passes the Scope Validator. Accepted proposals are written into graphium with the same 0.999 cosine dedup as a manual ingest; rejected ones are tallied with a reason.

Disabling a binding takes it out of dispatch on the next matching event — the worker reads the enabled flag fresh each time. In-flight runs always finish; there is no mid-run cancellation.

Proposals and the Scope Validator

A Cultivator never writes memory by tool-call. It returns proposals — draft statements, each carrying the scopes it wants to stamp on the result. Between the model's output and the write, the framework validates every proposal independently, so one bad proposal does not sink the rest:

Rejection reasonMeaningWho fixes it
malformed_scopeThe scope string is syntactically invalid (or a bare *).Engineering — a Kind bug.
scope_outside_catalogWell-formed, but outside the Kind's grant ceiling.Engineering — the Kind overreached.
tenant_narrow_violationIn the catalog, but excluded by your narrowing.You — loosen the override.
unknown_subjectA self:<uuid>:read access scope (or an about:<uuid> aboutness id) names someone not in the tenant directory.Directory — provision the user.

Note: A rejected proposal's text is never stored. Only a SHA-256 text_hash and the headline reason are kept on the run, so the audit trail leaks no content.

Two ceilings: what it may write, what it may read

Cultivators are least-privilege on both sides. Each side is narrowable per tenant, never broadenable.

  • Write ceilingthe scope patterns a proposal may carry (allowed_scope_patterns). Narrow it for your tenant with scope_grant_overrides. Validated by pattern match, so narrowing team:*:skills:read down to team:eng:skills:read is legal.
  • Read ceilingthe scopes the Cultivator runs under while reading DataHub (runtime_scopes), bound through row-level security so it physically cannot read more. Narrow it with runtime_scope_overrides. Validated by literal membership.

For either override, three values carry distinct meaning:

  • nullno narrowing. The binding tracks the live catalog, including any future widening of the Kind.
  • A subsetexplicit narrowing to exactly those scopes.
  • []block everything. A deliberate, total denial.

Warning: Tightening a ceiling takes effect on the next run. A statement a Cultivator used to write may then be rejected as tenant_narrow_violation and silently never appear in memory.

Connections and external tools

Most Cultivators are transformers: they re-express the event's own content into well-formed, scoped statements and touch nothing outside DataHub. They need no external access at all.

A minority are investigators that enrich from outside systems over MCP. An investigator does not bind servers to itself — external access is split into two halves, and neither is a per-binding console knob.

  • Tenant Connectiona tenant-level MCP server, registered once for the whole tenant: a name, a url, an auth_type (bearer, composio_account, or oauth2), a secret_ref, an allowed_tool_patterns allowlist, and an enabled flag. One Composio account or one internal MCP URL is one connection — Composio is not special, just one server among others. The allowed_tool_patterns set is the maximal surface the tenant permits on that server. Connections are managed out-of-band through the /v1/connections API (gated by connections:read / connections:write) — an engineer/ops surface, not the management console.
  • Connection Usewhat a Kind consumes, declared in code and reviewed in a PR (requires_connections): the connection name, the exact tools the Kind calls, and an on_missing policy. It is surfaced read-only on the Kind view; a tenant can never edit a Kind's external behaviour, because the tool calls live in PR-reviewed code, not in config.

Note: A secret_ref names a server-side secret — a namespace/key path such as tenant_acme/composio_id. Only its shape is validated when a connection is saved; the secret value never lives in the database and never passes through the console. The worker resolves it to an environment variable at MCP-call time.

Two gates, intersected. When an investigator calls a tool, the framework authorises the concrete tool name against both lists at once: the tenant connection's allowed_tool_patterns (the ceiling the tenant set) and the Kind's declared tools (the manifest engineering shipped). The call is allowed only if it matches both, so neither side's globs can widen the other.

Missing connections. The runner resolves a Kind's required connections at dispatch, before any LLM call. If a required connection (on_missing: skip, the default) is absent or disabled, the run is skipped outright and no model is invoked. A connection marked on_missing: degrade is treated as optional — the run proceeds without it and the Kind's own logic handles the gap.

Note: The outbound MCP transport ships in v0.1. Until then investigator Cultivators cannot actually reach out; transformers — the common case — are unaffected and reflect over DataHub context alone.

Runs, provenance, and tracing

Every dispatch records one run in cultivator_runs — an append-only audit row with its status, proposal counts, the model used and the connections it actually reached, and a Langfuse trace id when tracing is enabled. Each committed statement is stamped with a provenance_run_id back to the run that wrote it, so any piece of cultivated memory is traceable to its origin. Deleting a run leaves the statement; deleting a binding leaves the run history.

Tip: A skipped run is healthy, not a warning. It just means the Cultivator looked at an event and decided there was nothing to add.

PreviousConceptsContext (graphium)Next ConceptsChats