What an integration can provide
An integration is one folder under the integrations dir with a manifest.yaml
and a Python integration object — the module-level INTEGRATION (an instance of a
PersonalAgentIntegration subclass). It is the Home-Assistant async_setup_entry
analog: backend integrations run in-process at full trust, and contribute to a
config entry (an IntegrationConfig row created through the integration’s config
flow).
Everything an integration delivers is declared on the integration object
(src/personal_agent/integrations/integration.py in the backend repo), backed by the
contracts in capabilities.py and entities.py. This page is one section per
capability: the real method/descriptor, what it provides, and how the platform
consumes it.
!!! note
Every contribution method has a default that does nothing (returns [],
{}, or None). An integration is only “an X provider” if it overrides the
relevant hook. Existing integrations that override neither are unaffected.
Setup context
Every per-entry hook receives a SetupContext — everything needed to build a
contribution for one config entry:
| Field | Meaning |
|---|---|
entry_id |
the IntegrationConfig row id |
domain |
the manifest domain |
scope |
the entry’s scope (user / org / global) |
owner_sub, org_id |
the entry’s principal |
data |
the entry’s non-secret config (dict) |
secrets |
decrypted secret-field values — in-memory only |
version |
the entry’s config_schema_version |
!!! warning
ctx.secrets holds decrypted credentials. It lives in memory only and must
never be logged or serialized into Temporal inputs (BYOK Contract #5).
1. Tools / toolsets
async def async_setup_entry(self, ctx: SetupContext) -> list[Any]:
return [build_toolset(ctx)]
async_setup_entry returns a list of pydantic-ai toolsets (typically a
FunctionToolset). This is the agent-facing surface: each @ts.tool_plain
function becomes a callable tool. OpenProject is the worked example — it returns one
FunctionToolset built from the entry’s base_url + decrypted api_key, with tools
like openproject_list_projects, openproject_my_work_packages, and
openproject_create_work_package.
How it is consumed: ToolsetAssembler._assemble_integrations walks the chat’s
applicable config entries (IntegrationConfigRepo.list_applicable, precedence
user > org > global), calls async_setup_entry(ctx) per entry, and extends the
run’s toolset list with the result. One failing integration is logged and skipped —
it never fails the whole run. The contributed tool names are attributed back to the
integration for the composer’s integration picker.
Companion lifecycle hooks:
| Hook | When |
|---|---|
async_unload_entry(ctx) |
release resources tied to an entry (default no-op) |
async_migrate_entry(ctx, from_version) |
migrate stored config across config_schema_version |
!!! note
An integration can contribute no tools and still be useful — Tavily,
Signal, and TradingView all return [] from async_setup_entry and deliver
value through other capabilities below.
2. Entity types + state sync
An integration becomes an entity provider by declaring entity types and producing entities (the Home-Assistant entity analog).
Declaring entity types
def entity_types(self) -> dict[str, EntityStateTypeDescriptor]:
return {"work_package": _WORK_PACKAGE}
Each value is an EntityStateTypeDescriptor:
| Field | Provides |
|---|---|
key, name |
the type id and human label |
attributes |
a tuple of FieldDescriptor describing the entity’s fields |
rag |
True → entities are embedded for semantic search |
to_text |
Callable[[EntityStateRecord], str] rendering the text to embed (omit → default attribute serializer) |
category |
HA category hint "config" / "diagnostic" → internal entities (hidden from the agent’s default view) |
visible_default |
False → created hidden |
device_class |
HA semantics, e.g. "temperature", "task" |
state_class |
"measurement" / "total" / "total_increasing" |
unit |
unit of measurement, e.g. "°C", "%" |
actions |
a tuple of EntityStateActionDescriptor (see §3) |
world_kind |
a graph EntityKind key (e.g. "project", "task") → links a knowledge-graph node (the entities table) so facts/memories attach to the same thing (identity layer); None = live-state only |
message |
True → entities of this type are inbound comms messages (see §4) |
OpenProject declares project (world_kind="project", not RAG) and work_package
(world_kind="task", rag=True); Signal declares signal_message with rag=True,
a custom to_text, and message=True.
Producing entities — pull
async def async_sync_entities(self, ctx: SetupContext) -> EntityStateSyncResult:
...
return EntityStateSyncResult(records=..., full=True, entity_types=("project", "work_package"))
Return an EntityStateSyncResult of EntityStateRecords. A record carries
entity_type, external_id (the integration’s stable id), name, optional state,
attributes, an optional parent_type + parent_external_id (resolved to a parent
FK by the sync engine), and an optional device (DeviceInfo, grouping entities
under a get-or-created EntityStateDevice).
EntityStateSyncResult.full controls delete semantics: full=True (default) means
the records are the complete current set for entity_types, so stored entities
of those types not present are deleted; full=False is upsert-only. OpenProject
returns full=True; Signal returns full=False (a drain-style read that should
never delete).
How it is consumed: entities/sync_runner.py calls async_sync_entities on a
schedule (and on demand), diffs the result via EntityStateService, upserts,
emits entity.created / entity.updated / entity.deleted events, and indexes RAG
for rag=True types. EntityStateRecord.content_hash() drives change detection.
Producing entities — push, and seed-once
| Hook | Purpose |
|---|---|
EntityStateWriter (injected) |
the PUSH path — integration code (a tool / webhook) upserts or removes a single entity ad-hoc, through the same upsert→event→RAG pipeline (entities/writer.py) |
async_initial_entities(ctx) |
entities to create once when an entry is first configured — for entity state this system owns (user-created helpers) that must not be pull-synced (a pull would reset live state) |
3. Entity actions
A type that declares actions becomes interactive (the HA service analog).
EntityStateActionDescriptor(
name="set_value",
label="Set value",
fields=(FieldDescriptor(name="value", label="Value", type="number"),),
)
A non-empty actions tuple lets a dashboard card render a control (toggle / slider /
button) and lets the frontend POST /entities/{entity_id}/action. fields describes
the action’s args (an empty tuple = a no-arg action).
async def async_call_action(
self, ctx, *, entity_type, external_id, action, args, current
) -> EntityStateActionResult:
...
return EntityStateActionResult(records=(updated,), message="Done.")
How it is consumed: entities/actions.py dispatches the call to the owning
integration — only for actions the type declared in actions — passing
current (the entity’s present state/attributes). The returned
EntityStateActionResult.records are fed through the normal upsert→event→history
pipeline, so a state change emits entity.updated. message is an optional
human-readable note.
Integration-level actions (services)
async_call_action acts on one entity. An integration can also declare
integration-level actions (the HA services.yaml analog) that aren’t tied to any
entity, e.g. “send a notification” or “run a scene”:
def integration_actions(self) -> dict[str, IntegrationActionDescriptor]:
return {"notify": IntegrationActionDescriptor(
name="notify", description="Send a notification",
fields=(FieldDescriptor(name="text", label="Text"),))}
An IntegrationActionDescriptor (integrations/actions.py) has name (becomes the
agent tool name, namespaced per integration), description (the tool description),
and fields (typed args; empty = a no-arg action).
async def async_call_integration_action(
self, ctx, *, action, args
) -> IntegrationActionResult:
...
return IntegrationActionResult(ok=True, message="Sent.", data={})
How it is consumed: _assemble_integrations builds one FunctionToolset from the
declared actions (assembler/integration_actions_toolset.py) and folds it into that
entry’s toolset list, so each action becomes an ordinary agent tool that rides the
same untrusted-content / trust-tier gates and picker attribution as the
integration’s other tools. The tool validates its typed args and dispatches to
async_call_integration_action. IntegrationActionResult is the structured envelope
(ok / message / data); the default implementation fails closed
(IntegrationActionResult.fail) so a declared-but-unhandled action errors visibly.
4. Inbound comms (messaging) + the unified inbox
An entity type flagged message=True marks its entities as inbound comms
messages: they feed the unified inbox and are triaged on creation (importance,
contact-linking, HITL draft reply).
How it is consumed: comms/triage_setup.py:comms_entity_types(registry) derives
the triage trigger set by scanning every integration’s entity_types() for a
message=True descriptor — there is no hardcoded per-domain list, so a new messaging
integration auto-participates.
A comms integration backs message ingestion / sending / real-time listening with
three duck-typed providers from capabilities.py:
| Hook | Protocol | Method | Consumed by |
|---|---|---|---|
message_reader_provider(ctx) |
MessageReaderProvider |
list_messages(folder, limit) -> list[IncomingMessage] |
message ingestion → *_message entities |
message_sender_provider(ctx) |
MessageSenderProvider |
send(to, subject, body, in_reply_to, cc, attachments, message_id) -> dict |
only the draft-approval endpoint (comms/sender.py) - never a free agent tool |
message_listener_provider(ctx) |
MessageListenerProvider |
listen(controls) -> None |
the CommsListenerManager (comms/listener_manager.py) |
IncomingMessage is channel-neutral (external_id, sender, sender_address,
recipients, subject, body, thread_id, received_at, folder, from_me).
MessageSenderProvider.send is human-in-the-loop by contract: it is reached only via
the explicit approval endpoint. Its optional message_id lets the caller pin the
outgoing message’s id (an email Message-ID) so a new group thread has a stable root
that replies reference; channels with server-assigned ids ignore it.
A sender-capable integration also declares a few cheap, secret-free capability flags
read by the send-options / group-compose endpoints (no ctx, so they can be listed
without decrypting anything):
| Hook | Default | Meaning |
|---|---|---|
supports_message_initiation() |
True |
the channel can START a 1:1 to a bare recipient (vs only REPLY in a thread). Matrix returns False (its send target is an existing room id) |
supports_group_creation() |
False |
the channel can CREATE a named multi-party group from a name + members. Only channels with a persistent group identity declare it (Matrix rooms, Signal groups) |
group_surfaces_via_sync() |
True |
the group’s first (owner-sent) message comes back through the periodic sync (Matrix / Signal / Zulip). False for email (the Sent folder isn’t synced, so the group-compose endpoint pins the Message-ID itself) |
def group_creator_provider(self, ctx: SetupContext) -> GroupCreatorProvider | None:
...
When supports_group_creation() is True, group_creator_provider(ctx) returns a
GroupCreatorProvider whose create_group(name, members) -> str opens the room/group
and returns its send target (also the conversation’s stable thread id), used by the
group-compose endpoint (comms/sender.py).
A MessageListenerProvider runs its own loop until controls.should_run() is
False, calling controls.trigger() (debounced re-pull) when it detects activity
(IMAP IDLE, long-poll) or controls.sync_now() each cycle for poll-drain channels.
Signal is the poll-drain example: SignalListener.listen just calls
controls.sync_now() on a short timer because each /v1/receive consumes the queue.
!!! note
Signal is a comms-only account: async_setup_entry returns [] (no agent
tools), and the whole capability is the signal_message entity type plus the
three providers above.
5. Capability providers (web search / fetch / weather / compute)
A capability is a generic agent feature whose backend is supplied by whatever
integration the user configured. The generic first-party tool stays the same;
integrations register a provider for it. Providers are duck-typed runtime
Protocols — an integration returns an instance from the matching hook.
def web_search_provider(self, ctx: SetupContext) -> WebSearchProvider | None:
key = ctx.secrets.get("api_key")
return TavilySearchProvider(key) if key else None
| Hook | Protocol | Method |
|---|---|---|
web_search_provider(ctx) |
WebSearchProvider |
search(query, max_results) -> list[WebSearchResult] |
web_fetch_provider(ctx) |
WebFetchProvider |
fetch(url) -> str (a page’s readable text) |
weather_provider(ctx) |
WeatherProvider |
weather(latitude, longitude, hours) -> dict |
compute_provider(ctx) |
ComputeProvider |
available(), spawn(spec) -> ComputeHandle, stop(handle), is_alive(handle) |
Each provider exposes a name (search/fetch/weather) or id (compute) identifier.
A capability provider has no directly-selectable tools of its own — its manifest
lists the capability under provides (e.g. web_search, web_fetch), the UI hides
it, and the generic capability tool is offered instead.
First-configured-wins. ToolsetAssembler._resolve_capability_providers walks the
user’s full enabled integration set (precedence user > org > global, then
integration name) and _collect_providers keeps the first provider offered for
each of search / fetch / weather — it only assigns each slot if still None, and
stops once all three are filled. The resolved WebProviders then back the
web_toolset / weather_toolset for the run.
!!! note
Web/search/fetch/weather are ambient: they are resolved from ALL enabled
integrations, independent of the per-chat integration toolset selection.
web_fetch is local-first — a built-in in-process reader fills the slot when no
integration provides one. The integrations_enabled hard off-switch and the
governance tier gate (§9) still apply.
Compute precedence is the same shape: SandboxService picks the first applicable
integration offering a compute_provider (agent/sandbox/service.py), else the
admin-default built-in (settings.sandbox.provider, default docker). A
ComputeProvider starts a compute unit running the Computer Service with the four PA_*
env vars; ComputeSpec describes what to start and ComputeHandle what was started
(its provider id + native handle). Tavily is the worked search example; Hetzner /
AWS are the compute examples.
6. Surfaces (UI cards / views)
def surfaces(self) -> dict[str, SurfaceDescriptor]:
return {"trading": SurfaceDescriptor(name="Trading", icon="candlestick_chart",
description="...", config=_trading_surface_config())}
A SurfaceDescriptor is a Surface (chat mode / dashboard):
| Field | Provides |
|---|---|
name |
the surface’s display name |
config |
the Lovelace-style layout ({"views": [...], "arrangement": {...}}) |
icon |
optional icon |
description |
optional description |
show_in_nav |
whether it appears in navigation |
kind is derived from config (a chat view ⇒ a chat mode). Views are typed —
e.g. chat, or a cards view holding generic cards (iframe, entities, …).
How it is consumed: on config-entry setup, contrib._project_surfaces UPSERTs
each surface into the surfaces table (keyed by slug <domain>-<key>), stamped with
source_domain and read-only (re-projected on reconfigure). A user-scoped entry
yields user surfaces; an org/global entry yields global ones. Lifecycle follows the
config entry — removed when the last entry of the domain in that scope is deleted.
TradingView is the worked example: a trading surface that splits a chat view
beside a cards view holding one generic iframe card pointing at the TradingView
embed — a contributed surface, not a bespoke view type.
!!! note
Predefined dashboard card templates are a separate, manifest-level
contribution: frontend.cards in manifest.yaml (a list of {key, name, icon, description?, card: {type, ...}}), surfaced in the dashboard card picker. Both
OpenProject (an entities card) and TradingView (an iframe card) declare one.
7. Agents (delegatable sub-agent personas)
def agents(self) -> dict[str, AgentDescriptor]:
return {"analyst": AgentDescriptor(name="Markets Analyst", description="...",
instructions=..., requires_surface="tradingview-trading")}
An AgentDescriptor is a delegatable agent persona:
| Field | Provides |
|---|---|
name, description, instructions |
the persona |
tool_config |
optional overlay onto the parent run config (empty = inherit all tools) |
required_tools |
capability keys that gate when the agent is offered |
requires_surface |
a surface slug the agent is restricted to (None = any) |
How it is consumed: on config-entry setup, contrib._project_agents UPSERTs each
into the delegatable_agents table (slug <domain>-<key>, source_domain set),
read-only in the UI except enabled. A user entry yields user-scoped agents; an
org/global entry yields global ones. Lifecycle follows the config entry. TradingView
contributes a Markets Analyst agent gated to its trading surface.
8. Custom events + world-memory predicates
def event_types(self) -> dict[str, EventTypeDescriptor]:
return {"openproject.wp_overdue": EventTypeDescriptor(
event_type="openproject.wp_overdue", name="Work package overdue",
payload_schema=(...,))}
An EventTypeDescriptor registers a custom platform event:
| Field | Provides |
|---|---|
event_type |
the bus event name (recommend domain-prefixing) |
name |
human label |
payload_schema |
a tuple of FieldDescriptor describing the payload fields |
description |
optional |
How it is consumed: declared types are reconciled by the catalog sync
(integrations/sync.py) and emitted via the injected EventEmitter onto the same
personal_agent:events bus, so a Workflow’s triggers fire on them exactly like
built-in events.
An integration can also declare world-memory predicates for the entity graph:
def relation_types(self) -> dict[str, RelationTypeDescriptor]:
return {"github:reviewed_by": RelationTypeDescriptor(key="github:reviewed_by", name="...")}
RelationTypeDescriptor keys must be namespaced (domain:predicate); core and
privileged predicates are unprefixed/reserved and not integration-registrable (the
catalog sync logs and skips a non-namespaced or privileged key). Beyond key/name,
a descriptor carries inverse, cardinality (functional / set), subject_kinds
/ object_kinds, and the inference flags is_symmetric / is_transitive /
is_hierarchical. The sync stores the flags but never auto-enables inference
(inference_enabled = False) for any integration predicate, and stamps it
trust_tier = "trusted", source = the domain.
9. Health + webhooks
async def async_health(self, ctx: SetupContext) -> HealthResult | None:
...
async_health reports an entry’s live-connection health. HealthResult.status is
one of ok / degraded / error (class constants HealthResult.OK etc.) with a
short English detail. How it is consumed: the scheduled sync calls it, persists
the result on the config entry (shown in the integrations UI), and skips the pull
when the connection is down. Most integrations have no live connection and return
None; one with a backing service/bridge (e.g. WhatsApp) returns a result so a
disconnected account surfaces.
async def async_handle_webhook(self, ctx, payload: dict) -> EntityStateSyncResult | None:
...
async_handle_webhook handles an inbound PUSH webhook (the HA webhook analog).
External systems POST to /api/v1/webhooks/integration/{entry_id}?token=… (or pass
the token as Authorization: Bearer); the stateless per-entry token
(integration_webhook_token) is constant-time verified, then
async_handle_webhook runs detached behind a fast 202 ack. Return an
EntityStateSyncResult to upsert through the normal pipeline (events, RAG, live
pushes), or None to acknowledge without writing. A robust pattern is to ignore the
payload details and re-poll the source.
10. The trust model
Two independent axes, both authored in manifest.yaml.
trust_tier — trusted vs untrusted (Contract #13)
trust_tier: untrusted # default: trusted
trust_tier is "trusted" (default) or "untrusted". First-party folder
integrations are trusted. An integration that proxies an external service whose
output is attacker-influenced (an external MCP server, an arbitrary OpenAPI API)
declares untrusted.
How it is consumed: when an untrusted integration’s toolset is part of a run, the
assembler records its toolset object id; apply_untrusted_gate
(assembler/policy.py) then wraps every trusted toolset in a pydantic-ai
filtered view that drops the HIGH_PRIVILEGE_TOOLS set (send_email, web_fetch,
delegate_to, control_entity, device dev_* tools, …) from the model’s view. The
gate is presence-based and conservative: it engages for the whole run as soon as one
untrusted server is in the toolchain. The durable worker mirrors this per request.
required_tier — governance (the ordinal data axis, Contract #14)
required_tier: internal # name: unregulated | regulated | internal (default: unregulated)
required_tier is the minimum model-provider trust tier a run’s model must have
for this integration’s tools to be assembled. Author it by NAME — unregulated (0),
regulated (1), internal (2) — a typo raises loudly at manifest discovery. It
defaults to unregulated (0): an integration carries no tier-gated data unless it
says so. OpenProject declares internal, so its data only reaches an internal-tier
(on-prem) model.
How it is consumed: a model’s PROVIDER has a tier; the gate is
provider_tier >= required_tier (tier_ok in agent/governance.py).
_assemble_integrations computes the effective requirement
(effective_required_tier, allowing an admin override on the entry) and skips the
whole integration — tools and capability providers alike — when the run model’s
provider tier is below it. This is THE single governance axis (it replaced the old
required_provider_tags tag gate) and the same fail-closed gate runs at every model
resolution entry (inline, durable, workflows, comms).
| Tier | Name | Meaning |
|---|---|---|
| 0 | unregulated |
any external provider (default, fail-closed) |
| 1 | regulated |
compliant external (DPA/EU/no-train) |
| 2 | internal |
own / on-prem — cleared for the most-sensitive data |
11. Model providers
def model_providers(self) -> dict[str, ProviderSpec]:
return {"openai": ProviderSpec("openai", "OpenAI", "openai.OpenAIChatModel",
("gpt-4o", "gpt-4o-mini"))}
A static declaration (no ctx) that contributes one or more LLM providers to the
catalog the admin Providers page configures. There is no hardcoded catalog: the
ProviderRegistry builds it from the merged model_providers() of every discovered
integration, then overlays the admin-managed keys / base-urls from the
platform_provider table. The bundled integrations/<id>/ folders ship the generic
vendor providers (openai, anthropic, google, groq, mistral, bedrock, cohere,
huggingface, openrouter, deepseek, xai, cerebras, ollama, ollama_cloud,
openai_compatible); a deployment adds its own (a self-hosted endpoint, a bundled voice
server) by dropping in another provider integration or defining a custom provider in
the admin UI.
ProviderSpec field |
Provides |
|---|---|
id, label |
the provider id (catalog key) + its display name |
model_class_path |
the pydantic-ai model class (e.g. openai.OpenAIChatModel, anthropic.AnthropicModel) |
known_models |
curated names for the picker (live models are discovered from the provider API) |
openai_compatible, base_url, requires_base_url |
OpenAI-compatible endpoint wiring |
keyless |
a local backend reachable at a base_url with no credential |
Manifest reference
The non-capability manifest.yaml keys that shape discovery and the UI:
| Key | Meaning |
|---|---|
domain |
the integration id (^[a-z][a-z0-9_]*$) |
name, version |
display name + version string |
integration_type |
tool / service / knowledge |
config_flow |
whether it has a config-flow wizard |
single_instance |
whether more than one config entry is allowed (default true) |
iot_class |
trust/locality descriptor (local_in_process / local_polling / local_push / cloud_polling / cloud_push; only local_in_process is fully supported) |
requirements |
extra Python requirements (import-checked at load) |
requirements_extra |
the pip extra that installs requirements (defaults to integration-<domain>); when the requirements aren’t all importable the loader marks the integration unavailable and names this extra |
provides |
generic capabilities backed (web_search, web_fetch, …) |
dependencies, after_dependencies |
other domains this one needs / loads after |
config_schema_version |
the entry config schema version (drives async_migrate_entry) |
frontend.cards |
predefined dashboard card templates (§6); frontend.renderers / panels / slash_commands are reserved |
quality_scale |
internal / experimental / beta / stable (maturity signal; default experimental) |
documentation, issue_tracker, codeowners |
metadata |
A config flow (config_flow.py, a ConfigFlow subclass with async_step_*
methods) drives the setup wizard; each step’s form fields are FieldDescriptors the
generic frontend renderer shows. A field’s type is one of text / password /
secret / number / bool / select / note (a read-only display field that can
carry an image, e.g. a device-linking QR data-URL) plus the HA-style typed selectors
entity / device / area / duration / date / time / datetime / color;
filter narrows a selector and multiple makes entity/device/area/select a
multi-select. label / placeholder / description are i18n keys the engine resolves
from the integration’s translations/<lang>.json. password / secret field values
are envelope-encrypted and surface to setup hooks via ctx.secrets (password is
shown masked, secret is write-only).