Better Agent is extended through self-contained extension packages. An extension can add UI, backend HTTP routes, MCP tools, instructions, and skills. This document covers the two ways an extension reaches your machine — local (your own code, kept private) and remote (signed artifacts from the marketplace) — and the trust boundary that separates them.
The mental model:
- Local extensions live in a repo you control. Better Agent snapshots them into its state dir at runtime and re-syncs when they change. Your source never enters the Better Agent repo or any public place.
- Remote extensions are downloaded as signed artifacts. Their code is fetched on install and verified against a pinned key; you never need their source.
- The trust boundary is enforced by source type, not by the package. An extension cannot declare itself trusted — trust is bound to how it was installed.
Every installed extension has a source type that determines what it is
allowed to do. The source type is set by the installer and is never read from
the package, so a shipped extension cannot forge it
(backend/extension_store.py, is_first_party).
| Source type | How it gets installed | Trust | In-process | Consent |
|---|---|---|---|---|
better_agent_bundled |
Ships in a Better Agent release | First-party | yes | exempt |
better_agent_local |
Snapshotted from a local repo on your machine | First-party | yes | exempt |
better_agent_signed |
Signed artifact from the marketplace | First-party | yes | exempt |
marketplace / git / artifact |
Third-party install | Third-party | no | required |
First-party extensions are consent-exempt and are the only extensions
allowed to run in-process or declare backend_routes / internal_loopback
permissions. Third-party extensions always require explicit consent and never
run in-process.
This is what lets you treat your own extensions the same way the maintainer treats his: anything you install from a local repo you control is first-party.
A local extension is just a directory with a manifest. Better Agent discovers it, copies a snapshot into its state dir, and re-syncs when the source changes.
_local_private_extension_repo_root() resolves your extensions root in this
order:
BETTER_AGENT_MARKETPLACE_EXTENSION_REPO_PATH— explicit path to your extensions repo.<better-agent-repo>/better-agent-private/— if that sibling dir exists.<better-agent-repo>/itself.
Under whichever root wins, Better Agent scans
extensions/*/better-agent-extension.json and installs every package it finds
as better_agent_local (first-party).
So as a cloner you have two equivalent options:
- Keep extensions in your own repo and point at it:
Your repo:
export BETTER_AGENT_MARKETPLACE_EXTENSION_REPO_PATH=/path/to/my-extensionsmy-extensions/extensions/<id>/better-agent-extension.json. - Or drop them into your Better Agent checkout at
extensions/<id>/better-agent-extension.json(fallback 3 above).
Either way the source stays in your repo. Nothing is copied into the public Better Agent repo, and nothing is uploaded anywhere.
my-extensions/
extensions/
<your.id>/
better-agent-extension.json # manifest (required)
ui/ # frontend assets (optional)
backend/ # python backend module (optional)
mcp/ # MCP server (optional)
- On startup (and on reconcile), Better Agent snapshots each local package into
$BETTER_AGENT_HOME/extensions/installed/<id>/versions/<sha>/. - The version key is the git HEAD commit of your extensions repo
(
_private_extension_commit_sha). When you commit a change, the next reconcile detects the new HEAD and re-snapshots — edits take effect on the next store reconcile without a manual reinstall. - Local extensions are first-party, so they skip the consent prompt and may use
backend_routes/internal_loopback. Like every install, they still land disabled and you turn them on once; later reconciles keep that choice.
State isolation: all snapshots live under
$BETTER_AGENT_HOME(defaults to~/.better-claude). Never write scripts that touch that path directly — go through Better Agent's own paths/config.
{
"kind": "better-agent-extension",
"id": "acme.helpers",
"name": "Acme Helpers",
"version": "0.1.0",
"description": "My private helpers.",
"surfaces": ["backend_feature", "frontend_feature"],
"entrypoints": {
"frontend": "ui/index.html",
"frontend_modules": [
{
"slot": "settings",
"id": "helpers",
"label": "Acme Helpers",
"kind": "iframe",
"module": "ui/index.html"
}
],
"backend_module": "backend.routes"
},
"permissions": { "backend_routes": true, "internal_loopback": true },
"protocol": {
"version": 1,
"smoke_test": {
"required_paths": ["better-agent-extension.json", "ui/index.html", "backend/routes.py"],
"python_modules": ["backend.routes"]
}
}
}id rules: 3–80 chars, lowercase, [a-z0-9._-]. Use a namespace prefix you
own (acme., not ofek-dev. or better-agent.).
Naming a wrapper around a third-party tool: if an extension is just a
thin integration around someone else's product (e.g. shelling out to a
third-party CLI binary), suffix the id's local part with -ba-extension
(e.g. acme.officecli-ba-extension, not acme.officecli). This keeps the id
from reading as if you authored or are affiliated with the wrapped product —
the suffix marks it as "our Better Agent extension around X", not "X".
Extensions that ARE your own original product don't need the suffix.
surfaces is a subset of {backend_feature, frontend_feature, runtime_mcp, instructions, skills, daemons} (_ALLOWED_SURFACES). Entrypoints declare the
concrete implementations.
entrypoints.frontend— the HTML asset root.entrypoints.frontend_modules— list of{ slot, id, label, kind, module }.slot:settings(a new Settings page section), a quick button, or a page.kind:iframe— a self-contained HTML page rendered in a sandboxed iframe. This is the simplest way to ship a packaged UI with no build step.module— a bundled.entry.jsdynamically imported into the app.
Frontend assets are served at
/api/extensions/<id>/frontend/<path> (resolve_frontend_asset). Asset paths
are confined under the frontend directory; traversal is rejected.
entrypoints.backend_module— a python module path (e.g.backend.routes) exportingcreate_router(context) -> fastapi.APIRouter. It is mounted at/api/extensions/<id>/backend/*. Requirespermissions.backend_routes: true.
entrypoints.mcp— one or more MCP server configs (pythonscript,args,env). Requirespermissions.internal_loopbackto receive the internal backend token. An optionaldescription(<=500 chars) is shown as helper text under the server's toggle in Extension Settings — use it to explain what the tool does, and to call out when it's an alternate route to a capability another entrypoint (e.g. a sibling skill) already provides.
Inject managed instruction blocks or contribute skills. These surfaces don't
execute code. Skill entries under entrypoints.skills also accept an
optional description (<=500 chars), shown as helper text under the skill's
toggle in Extension Settings for the same reason as the MCP description
above.
entrypoints.daemons— list of{ name, module, lifecycle, restart_policy, env_allowlist, ports }.lifecycle: "backend"— a supervised child of the backend process; starts and stops with it (backend/extension_daemons.py).lifecycle: "supervisor"— outlives backend restarts. The platform daemon host (top-leveldaemonhostpackage; spawned byrun.sh, run in-process by the desktop shell) installs a copy underba_home()/daemons/<ext>/<name>/and supervises it. Installs are gated onpython -m <module> --selftestexiting 0, a last-known-good copy is kept for crash rollback, and updates apply only in quiescent windows (no child running). Requirespermissions.daemons: "supervisor".
- Daemons run with a scrubbed environment: only base vars, the Better Agent
home vars, and the declared
env_allowlist— never auth tokens or secrets. portsdeclares any ports the daemon binds; platform-reserved ports are rejected at validation time.- Every daemon
modulemust be covered byprotocol.smoke_test.python_modules. - The backend publishes the desired supervisor set to
ba_home()/daemons/registry.jsonand serves host status as a projection atGET /api/extensions/daemons/state. Registry entries are removed only on explicit uninstall/disable — never because the active checkout lacks the extension.
Declared in permissions as booleans, "optional", or scoped lists. Sensitive
capabilities (backend_routes, internal_loopback, in_process_execution,
secrets, filesystem, …) are honored only for first-party extensions;
third-party extensions must request them and win consent. Changing the declared
set invalidates prior consent (permission_consent_fingerprint).
Remote extensions are distributed as signed artifacts. You browse a catalog, pick an extension, and Better Agent downloads the artifact and verifies its signature before installing.
- Catalog fetch:
GET <marketplace>/extensions.json→{ "extensions": [ { id, name, version, …, metadata_url } ] }. - Per-extension metadata:
GET <marketplace>/extensions/<id>/metadata→{ artifact_url, artifact_sha256, signature, signature_alg }. - Install:
POST /api/extensions/installwith the metadata. Better Agent downloads the artifact and verifies it.
_verify_artifact_signature trusts only the pinned public key — never a
key supplied by the metadata. This means an attacker who tampered with the
metadata cannot ship a malicious artifact plus a matching key and self-validate
it. Specifically:
- The pinned key comes from
BETTER_AGENT_MARKETPLACE_PUBLIC_KEY(or the built-in default). A metadata-supplied key is ignored. - Signature is Ed25519 over
{ artifact_sha256, extension_id, version }. _validate_artifact_urlrequireshttps://and rejects embedded credentials (BETTER_AGENT_ALLOW_INSECURE_MARKETPLACE_ARTIFACTS=1opts in to insecure, for local testing only)._download_artifactcaps size at 50 MiB (_MAX_ARTIFACT_BYTES), rejects symlinks, and blocks path traversal (.., absolute paths) on extraction.- Any failure raises and the install is aborted. Nothing partial is kept.
Installed marketplace artifacts are better_agent_signed (first-party) only
when they verify against the pinned key. Unsigned/third-party installs are
marketplace/git/artifact source types — third-party, consent-gated, never
in-process.
| Env var | Purpose |
|---|---|
BETTER_AGENT_MARKETPLACE_BASE_URL |
Catalog/metadata/artifact base URL. |
BETTER_AGENT_MARKETPLACE_PUBLIC_KEY |
Override the pinned signing key. |
BETTER_AGENT_ALLOW_INSECURE_MARKETPLACE_ARTIFACTS |
1 to allow non-https artifact URLs (dev only). |
The boundary is mechanical, not social:
- Your private source stays in your repo. Local extensions are read from
your extensions repo and snapshotted into your local
$BETTER_AGENT_HOMEonly. Better Agent has no upload path for local extension source. - The marketplace is a one-way publish for you. If you choose to distribute an extension, you sign artifacts and host them yourself; clients verify against the pinned key. You decide what to publish — unpublished extensions in your local repo are never reachable by anyone else.
- Trust is not self-declarable. A package cannot elevate itself to
first-party or grant itself
backend_routes/in-process execution by editing its manifest. Only the install path grants trust. - Namespace your ids. Use your own prefix (
acme.*). Theofek-dev.*andbetter-agent.*namespaces are reserved; colliding ids can be obsoleted.
# 1. Make an extensions repo
mkdir -p my-ext/extensions/acme.helpers/ui
cat > my-ext/extensions/acme.helpers/better-agent-extension.json <<'JSON'
{ "kind": "better-agent-extension", "id": "acme.helpers", "name": "Helpers",
"version": "0.1.0", "surfaces": ["frontend_feature"],
"entrypoints": { "frontend": "ui/index.html",
"frontend_modules": [{ "slot": "settings", "id": "helpers",
"label": "Helpers", "kind": "iframe", "module": "ui/index.html" }] } }
JSON
echo '<!doctype html><h1>Helpers</h1>' > my-ext/extensions/acme.helpers/ui/index.html
# 2. Point Better Agent at it
export BETTER_AGENT_MARKETPLACE_EXTENSION_REPO_PATH="$PWD/my-ext"
# 3. (Re)start the backend. A "Helpers" section appears in Settings.To browse and install public extensions instead, open the Marketplace
section in Settings (or use the ofek-dev.marketplace MCP tools) and install
from the catalog — they are verified against the pinned signing key on the way
in.