The Provider Boundary — What Belongs in Core, and Why
PersonalClaw’s central architectural tenet: the core package is provider-agnostic. Core contains capability-enabling mechanisms (protocols, registries, resolvers); every integration with a specific provider — a vendor’s endpoints, auth, catalogs, binaries, wire quirks — lives in an app bundle. This document records where the line is drawn and, for each surface that looks vendor-flavored but stays in core, why that judgment was made.
Paths are relative to PersonalClaw/src/personalclaw/ unless noted;
apps/ is the first-party bundle directory at the workspace root.
Why a boundary at all
Section titled “Why a boundary at all”The boundary is what makes the system composable: any model provider, channel,
agent runtime, or search engine can be added by installing an app — no core
edits. It also keeps core testable in isolation (the core test suite collects
and passes without the sibling apps/ directory present) and keeps
vendor-specific dependencies (SDKs, scrapers, block-format builders) out of the
core dependency set. Apps import core only through the personalclaw.sdk.*
facade (26 modules), enforced by tests/rails/test_apps_import_boundary.py.
The boundary-judgment table
Section titled “The boundary-judgment table”Not everything with a vendor’s name in it is drift. Some surfaces are protocol or reference data that core must own to function. Each row below is a deliberate, documented judgment (also recorded in-module at each site).
| Surface | Home | Judgment |
|---|---|---|
llm/anthropic.py, llm/openai.py | core | Wire-protocol clients only. They speak the Anthropic/OpenAI HTTP message formats — formats many providers reuse. Neither module calls register_type at import; registration is owned by apps/anthropic-models/provider.py and apps/openai-models/provider.py. Core ships the client; an app decides it is used. |
stt/, tts/, image_gen/ openai_provider.py | core | OpenAI-compatible protocol clients. The /v1/audio, /v1/images shapes are a de-facto protocol implemented by many vendors. Vendor catalogs (which model ids exist, their properties) are contributed by apps via the media_catalogs.py catalog-contribution seam — apps/openai-models owns the OpenAI ones. |
acp/dialect.py (ClaudeCodeDialect, CodexDialect) | core | Protocol-shape strategies, not vendor logic. They encode the small frame-shape differences between the Zed-maintained ACP adapters. App bundles select a dialect by id (options["dialect"]); nothing in core infers a vendor from argv or binary names. |
llm/catalog.py family map + infer_capabilities() + refused_sampling() + thinking_shape() + returns_thinking() | core | Fallback-only reference data. Providers that declare capabilities always win, and so does a vendor’s own model record: a branded app hands its vendor’s /models records to register_branded_app(spec, capabilities_of=…), and the shared listing reads a record’s root (the model an alias serves). The markers classify only what the vendor’s listing leaves unsaid, and families no job here binds (rerankers, moderation and safety classifiers, completion-only, realtime-only and Responses-only models) read as no capability, so no picker offers them. refused_sampling() names the sampling parameters a model family’s vendor documents it refusing (a 400), so the Anthropic wire client leaves them off and reports why; the vendor’s Models API describes thinking and effort per model but not sampling, so there is no record to read instead, and a model it does not name is sent everything. thinking_shape() says how a model family takes a reasoning effort (an adaptive effort level, or a thinking token budget; an id it does not recognise keeps the budget), and returns_thinking() whether a request may hand a model back the thinking blocks it wrote (never on a model that binds them to an exact prefix, nor on one it does not recognise). The blocks themselves travel as a neutral, opaque reasoning record (llm/reasoning_record.py) that the native loop strips from every request to a provider that does not declare reads_reasoning, so no app’s provider receives it. Same class as public model-pricing tables (pricing.py, model_pricing.json) — data about the world, not an integration. |
security.py xox[bpas]- token patterns; sandbox.py SLACK_* env denylist | core | Secret-DETECTION data. These patterns exist to redact and block leaked credentials. Renaming them to something generic would break the control they implement. Deliberate keep. |
CRED_SLACK_* constants | core config/loader.py, re-exported by sdk/channel.py | The literal .env credential-store key names (SLACK_BOT_TOKEN, SLACK_APP_TOKEN) that existing installs already hold. The loader is the credential store’s home (the bottom layer, below all apps); sdk/channel.py re-exports them as the app-facing surface, so the slack app imports via the SDK and no import direction is inverted. Renaming the keys would break existing installs for zero gain. |
onboarding_import/ (registry.py and the sources/ it lists), mcp_discovery.py, packs/external_formats.py and the dashboard/handlers/agent_export.py route over it, and dashboard/handlers/mcp.py’s sync of MCP servers into Claude Code’s own config | core | Interoperability formats. These read another agent tool’s setup and history so they can be imported, or write PersonalClaw’s agents and MCP servers into another tool’s files. They know where that tool keeps its files and what shape the files have, and nothing else: they hold no account, make no network call, import no vendor SDK and run none of the other product’s code. The floors they stand on are core’s, and every one of them applies: a credential-bearing path is never opened and a secret found in text is counted, never copied (onboarding_import/floors.py); a file PersonalClaw did not write is never overwritten (packs/external_formats.py); a config that exists and cannot be read is refused rather than replaced on a write (ConfigUnreadable in dashboard/handlers/mcp.py); and another tool’s files are read only when the owner asks for that tool or has allowed it in Settings. Each file is a format: keep in provider-boundary-keeps.txt. |
| Everything else vendor-specific | apps/ bundles | Endpoints, auth flows, catalogs, binary resolution, block/attachment formats, scraping — all bundle-resident. |
Rows 1–2 are machine-enforced, not merely declared (#3500). tests/rails/test_provider_boundary_residue.py sweeps every core *.py for vendor SDK imports (ast Import nodes, so a comment or docstring mentioning a vendor cannot be counted) and vendor credential/secret literals, and requires each hit to be a listed keep in
provider-boundary-keeps.txt carrying its judgment — both directions, so a stale entry fails too. The four OpenAI-protocol clients rows 1–2 name hold the whole of core’s vendor SDK import surface: eight import openai statements, all lazy, all listed. Until that sweep’s vendor set was widened it knew only the channel SDKs, so those eight were a declared exception nothing checked, and the sweep was green on a tree it could not see — the boundary had a document and a rail that were looking at different things. The vendor set is derived from pyproject.toml’s provider-SDK extras plus the SDK distributions app manifests declare, so adding a vendor has a rule rather than a guess.
Enforcement is deliberately a baseline, not a relocation: the table above records where the line is drawn today, and moving any of these paths out of core is roadmap work that now has something to move against.
A row that is gone: app logger roots. This table used to list constants.APP_LOGGER_ROOTS as a core keep and call apps registering their own roots “deliberately not built yet”. Both claims were false: constants.py has no such name, and apps could already declare their logger names in the manifest. That declaration is gone too: an app’s log lines are recognised by the code that logs them, not by a name. The log’s sinks (log_sinks) show a record at the owner’s level when its call site is under the directory a loaded app’s code came from (app_code.loaded_app), so an app names nothing and core names no app’s logger. It is not an in-core exception at all, which is why it has no row. A stale exception list is worse than no list, because it reads as a ruling and answers wrongly in both directions.
The app-bundle landscape
Section titled “The app-bundle landscape”The families an app can belong to are not a list this document keeps — they are
exactly PROVIDER_TYPES (apps/manifest.py), which a manifest is validated
against at install time. Read that frozenset for what exists; read
PersonalClawApps for who
implements it. What matters here is where the boundary sits inside each family:
- Model providers come in three construction shapes, all bundle-resident:
protocol-thin branded apps (built on
sdk/provider_helpers.pyregister_branded_app), generic-endpoint apps taking a base URL, and full-protocol apps owning their own wire translation. Local-inference apps (whisper, TTS, embeddings, diarization) additionally implement thelocal_models/management contract. - Search providers register through
search_providers/; the zero-config floor is a declaredkeylesscapability, not a vendor name in core (search_providers/registry.py::_keyless_provider: first registered keyless provider wins). A search that falls back to it says so (SearchResult.fallback), and a provider’s state is what its last search measured (last_check, recorded by its Test and by every search), never what its settings suggest. - The chat zero-config floor is the model axis’ version of that same rule.
apps/native/bundled-chatruns a small Apache-2.0 GGUF model in-process onnumpy, so an install answers a first turn with no provider and no key. The weight is not in any distribution artifact — not the wheel, not the container image, not the desktop bundle — but is fetched once into$PERSONALCLAW_HOME/models/bundled-chat/on an explicit click, digest-verified against the record (a ~147 MiB wheel is over PyPI’s 100 MiB per-file limit and a cost every installer would pay). So the first chat needs network and everything after it does not — and no surface claims otherwise. It is an app bundle and adds no in-core exception: the executor is a format reader plus arithmetic (no endpoint, no auth, no catalog, no wire dialect), andnumpywas already a core dependency for the in-wheelnative-vector-memoryapp — so core gains no dependency and no vendor string. The download itself adds no route either: the app implements core’s existingLocalModelProvidercontract, soPOST /api/models/downloads+ the SSE progress stream + cancel + delete all serve it generically, and no route or field carries this app’s name.GET /api/onboarding’schat_download_offeris derived from the local-model registry (capability + catalog), not from a hard-coded app. What core did gain is one declared flag,ProviderEntry.floor, which implicit fallback sorts LAST (providers/provider_bridge.py::_resolve_from_config_registry). That flag is necessary rather than cosmetic: an app registers its floor entry while its module is imported, which happens beforesync_entries_from_config()replays the user’s own rows, so on insertion order alone a floor would beat every configured provider. The entry is registered in memory only — never written toconfig.json— so it vanishes with the bundle instead of leaving a stale pin naming a provider that is gone, and it is registered only once the weight is actually on disk (refresh_registration()re-evaluates after a download or a delete, so neither needs a restart). Which model is signed off, under which licence, with which digest and source pin, is recorded in bundled-model-signoff.txt;scripts/verify_wheel.pyasserts at release that the wheel stays small and carries no weight-shaped member, so a weight creeping back in reds the release rather than the upload. - Agent apps own binary resolution, dialect selection, and login argv; core
acp/is the vendor-neutral protocol layer. - OCR engines register through
ocr/(OcrProvider, re-exported bysdk/ocr.py). Deliberately its own type rather than amodelservingimage_modality: an engine takes no prompt and must be byte-stable for the same input, which the model seam does not promise. Core ships no engine, so an empty registry is the normal state — the ingestion graph’socrnode has a model-backed backend (vision-llm) and an engine-backed one (engine) and runs whichever can run, falling back to today’s graceful skip when neither can. The true-type gate every engine must pass its input through (ocr/filetype.py) is core’s, not each bundle’s: a magic-number table copied per bundle is a table that drifts per bundle. - Vector-store backends register through
vector_stores/(VectorStoreProvider, re-exported bysdk/vector_store.py) and let knowledge chunk-vector search run against a store the user owns — Qdrant, pgvector, Chroma. Core ships no vector-store client and holds no core exception for one: a Qdrant or Chroma REST dialect is one vendor’s API, not a de-facto multi-vendor protocol in the/v1/chat/completionssense, so the judgment in the table above lands the whole client in the bundle. What core keeps is everything that turns a similarity into a rank — the_VECTOR_MIN_SIMILARITYfloor, the max roll-up to the parent document, the archived/active liveness filter and RRF fusion — so a backend can move where the nearest-neighbour work happens without being able to change what a search means. An empty registry is the normal state: with nothing bound, retrieval is the bundledsqlite-vec/FTS5/graph path unchanged. Enabling exactly one such app IS the binding (there is no second config field naming it, so enablement and “which store is knowledge searching” cannot disagree), and two enabled at once is refused rather than arbitrated — seevector_stores/base.pyfor the fail-soft-never-substitute rule. - Channel apps own the vendor transport and delivery both ways.
slack-channelis the completed reference — see inbox-channels.md and build-a-channel-app.md. skills-shis a marketplace app rather than a single provider, and backend+UI apps contribute their own dashboard pages behind auiblock plus a subprocess backend.
How resolution works (no vendor names in the path)
Section titled “How resolution works (no vendor names in the path)”- Bindings —
~/.personalclaw/active_models.jsonmaps use cases (chat, background, embedding, ingestion, stt, tts, …) to an ordered list of provider/model refs; the first resolvable ref wins. - Build —
llm/registry.pyregistry.buildconstructs the client via the factory the owning app registered withregister_type. A per-sessionmodeloverride kwarg is threaded through and honored by every factory. - Catalog/management —
ModelCatalog/ModelManageris the shared catalog and management seam; provider instances live inconfig.jsonproviders[](credential-chain providers store no secret material there). - Local models —
local_models/provider.pydefines the unifiedLocalModel/LocalModelProvidercontract (list/download/delete plus gated/source metadata), orthogonal to the inference ABCs. Download detection must probe every filesystem layout a provider writes; deletes clear all layouts; a binding to a catalog-absent model surfaces as a synthetic not-downloaded row rather than disappearing. Where a model runs is one answer, asked per model:llm.registry.served_on_this_machine(entry, model). Its price (a known $0 here), the spend caps, local-first routing and itsAlways localpin, the outbound scan, the local-model queue and the Models page’s fit chip all ask it. A type says what it can:ProviderCapability.in_process(it runs its models inside the gateway),hosts_model(it runs them where its endpoint is), and apasses_onprobe registered withregister_typefor the models such a server answers from somewhere else. The bundled Ollama app’s probe answers from the host its server names for a model (remote_hostin/api/tags,/api/showand each answer), and before the server has said anything, from thecloudtag Ollama gives its cloud models. - Subscription credentials — a provider whose vendor bills by subscription
has no API key to paste; it rides an agent CLI the user already signed in.
llm/subscription_credentials.pyknows how to read a declared credential store read-only and nothing else: the app registers aSubscriptionSource(paths, JSON key walk, expiry shape, and its ownlogin_hintnaming its own login verb) and names it inBrandedProviderSpec.credential_source. Core ships no source rows — do not add one; a vendor path or login verb in core is the boundary violation, not a shortcut. The resolver sits at one fixed place in the credential order (belowentry.credentialandoptions.api_key, abovespec.api_key_env), never writes/refreshes the store it reads, and reports not-signed-in through theavailability()probeproviders/loader.pyderives from that declaration.
Case study: how Slack left core
Section titled “Case study: how Slack left core”The clearest illustration of the tenet is the Slack extraction (originally 13,097 LOC across 11 core modules). The end state:
- Core kept the seams:
channel_transports/(inbound; aChannelTransportProviderABC withstart_inbound(services)) andchannel_delivery.py(outbound; theChannelDeliveryprotocol —deliver_text,deliver_rich,upload_attachment, streaming primitives,resolve_user_name,build_thread_link, …). Core never constructs a vendor URL — even the “open this thread” deep link is produced by the app behindbuild_thread_link. - The app got the vendor logic:
apps/slack-channel/slack_runtime/— transport, runtime facade, delivery, events, interactions, blocks, files, and settings with a loud one-timemigrate_from_core(). - Generic residue was extracted, not deleted: LLM text utilities misfiled
in the Slack module became core
textfmt.py; the gateway orchestrator (which was ~95% core boot logic living inslack/gateway.py) became coregateway.py. - Naming followed the seam: routes are
/api/channels/reply-targets,/api/channel/profile; the delivery grammar isdeliver="channel[:...]"; session-origin labels areorigin="channel". Old Slack-named routes 404 — clean break, no aliases.
The same pattern applied to the Ollama client (llm/ollama.py, 1002 LOC → an
ollama-models app bundle) and the vendor-specific inbox source (deleted
outright — channels are channel providers, not inbox sources).
The Ollama bundle has since moved into the wheel, as apps/native/ollama-models/,
so a fresh install gets a chat and embedding provider with no credential and no
second repository to clone. That changed
where the bundle ships, not where the line is. Its client, catalog and
endpoints are still bundle code behind the model seam; it imports core only
through personalclaw.sdk.* and registers its own type at import, so core gained
no llm/*.py module back. See app-platform.md for the
native-bundle contract it meets. What the move did change is removability: a
native app is locked on, so it can be neither turned off nor uninstalled.
Rules of thumb for contributors
Section titled “Rules of thumb for contributors”- Adding a provider? Start an app bundle. If core needs an edit, you are probably missing a seam — propose the seam, not the vendor patch.
- A vendor name in core is acceptable only as (a) a wire-protocol implementation many vendors share, (b) fallback-only reference data that declared capabilities override, or (c) secret-detection material. Document the judgment in-module.
- Apps import core only via
personalclaw.sdk.*. The boundary test will fail your PR otherwise. - Registration belongs to the app (
register_type, transport registration, catalog contribution) — never module-level side effects in core.