Skip to content

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.

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.

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).

SurfaceHomeJudgment
llm/anthropic.py, llm/openai.pycoreWire-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.pycoreOpenAI-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)coreProtocol-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()coreFallback-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 denylistcoreSecret-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_* constantscore config/loader.py, re-exported by sdk/channel.pyThe 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 configcoreInteroperability 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-specificapps/ bundlesEndpoints, 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 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.py register_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 the local_models/ management contract.
  • Search providers register through search_providers/; the zero-config floor is a declared keyless capability, 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-chat runs a small Apache-2.0 GGUF model in-process on numpy, 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), and numpy was already a core dependency for the in-wheel native-vector-memory app — so core gains no dependency and no vendor string. The download itself adds no route either: the app implements core’s existing LocalModelProvider contract, so POST /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’s chat_download_offer is 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 before sync_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 to config.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.py asserts 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 by sdk/ocr.py). Deliberately its own type rather than a model serving image_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’s ocr node 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 by sdk/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/completions sense, 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_SIMILARITY floor, 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 bundled sqlite-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 — see vector_stores/base.py for the fail-soft-never-substitute rule.
  • Channel apps own the vendor transport and delivery both ways. slack-channel is the completed reference — see inbox-channels.md and build-a-channel-app.md.
  • skills-sh is a marketplace app rather than a single provider, and backend+UI apps contribute their own dashboard pages behind a ui block plus a subprocess backend.

How resolution works (no vendor names in the path)

Section titled “How resolution works (no vendor names in the path)”
  1. Bindings — ~/.personalclaw/active_models.json maps use cases (chat, background, embedding, ingestion, stt, tts, …) to an ordered list of provider/model refs; the first resolvable ref wins.
  2. Build — llm/registry.py registry.build constructs the client via the factory the owning app registered with register_type. A per-session model override kwarg is threaded through and honored by every factory.
  3. Catalog/management — ModelCatalog/ModelManager is the shared catalog and management seam; provider instances live in config.json providers[] (credential-chain providers store no secret material there).
  4. Local models — local_models/provider.py defines the unified LocalModel/LocalModelProvider contract (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 its Always local pin, 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 a passes_on probe registered with register_type for 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_host in /api/tags, /api/show and each answer), and before the server has said anything, from the cloud tag Ollama gives its cloud models.
  5. 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.py knows how to read a declared credential store read-only and nothing else: the app registers a SubscriptionSource (paths, JSON key walk, expiry shape, and its own login_hint naming its own login verb) and names it in BrandedProviderSpec.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 (below entry.credential and options.api_key, above spec.api_key_env), never writes/refreshes the store it reads, and reports not-signed-in through the availability() probe providers/loader.py derives from that declaration.

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; a ChannelTransportProvider ABC with start_inbound(services)) and channel_delivery.py (outbound; the ChannelDelivery protocol — 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 behind build_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-time migrate_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 in slack/gateway.py) became core gateway.py.
  • Naming followed the seam: routes are /api/channels/reply-targets, /api/channel/profile; the delivery grammar is deliver="channel[:...]"; session-origin labels are origin="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.

  • 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.