# 10 · Host Providers

Read this when an extension needs Cedros to make calls through the site's
configured intelligence, image generation, voice, storage, email, or blockchain
providers (or, for managed extensions only, domains).

A wasm server extension calls `providers.execute(...)` and may call
`providers.resolve-default(...)` for host-selected intelligence readiness.
Both are scope-checked against the named manifest `providerAccess[]` entry. All
wired providers route
to the **main backend's** own subsystems with the host's existing limits
(canonical contract: [`07-wasm-server-backends.md`](07-wasm-server-backends.md)):

- **storage** — put/get/delete files in the site's media storage
  (extension-namespaced keys; base64 content). Configured object storage is
  used when present; otherwise the seam falls back to local starter storage
  under `.cedros-data/local-media`, capped at 20 extension objects per
  extension namespace and 10 MiB per `put`.
- **intelligence / imageGeneration / voice** — the CMS AI gateway built from
  the site's configured AI providers; payload is an `AiExecutionRequest`. The
  gateway enforces model allowlisting, capability checks, input-size + rate
  limits, and a grant is operation-scoped.
- **email** — the site's configured default email provider + sender identity +
  rate limits; operation `send`; payload is
  `{ to, subject, htmlBody?, textBody? }`. There is no fallback sender;
  unconfigured email returns stable code `cedros.provider.not_configured`.
- **blockchain** — read site-owned wallet/RPC readiness with
  `blockchain.config.read`; the response includes wallet/RPC metadata and
  `rpcSecretConfigured`, never the raw RPC secret.
- **domains** — intentionally not exposed (owned by a host-managed domain
  provider); returns "not available".

**Provider ownership rule:** Cedros owns provider configuration. Extension
admin pages may configure extension feature behavior, but they must not
rebuild provider credential/config surfaces. Email provider setup, delivery
webhooks, deliverability, image/storage/voice/intelligence provider
credentials, model routing, storage buckets, and sender identity stay in the
host/provider runtime. Declare `providerAccess[]`, call the provider seam, and
show feature-specific disabled states when the site provider is unavailable.

## Declaration model

- Extensions use Cedros host providers instead of shipping their own API keys,
  model routers, registrar clients, storage clients, voice clients, or email
  senders.
- Manifest provider access lives in `providerAccess[]`
  ([field reference](04-manifest-reference.md)).
- Host-native and CMS-proxy runtime calls include a provider call context with
  `extensionId`, optional `accessId`, optional `feature`, optional
  `description`, and optional `tags`. The wasm seam
  `providers.execute(access-id, provider, operation, payload)` carries no
  context fields — via wasm, attribution is extension id + access id only.
- Keep settings/secrets separate: provider credentials remain site-owned and
  must not be copied into extension manifests, logs, analytics, or site-brain
  records.

## Provider kinds and scopes

`providerAccess[].provider` values:

- `intelligence`
- `imageGeneration`
- `voice`
- `domains` (catalog/scope naming only — `domains` is rejected in
  `providerAccess[]`; manifest validation refuses it because the generic
  provider host cannot route it. Live domain operations go through Cedros
  Managed API, not a guest provider grant.)
- `storage`
- `email`
- `blockchain`

Use stable, descriptive scopes:

- `chat.generate`
- `image.generate`
- `voice.synthesize`
- `voice.transcribe`
- `domain.availability.search`, `domain.availability.suggest`,
  `domain.pricing.quote`, `domain.order.create`, `domain.order.read`,
  `domain.connect-existing`, `domain.portfolio.read`, `domain.renewal.policy`,
  `domain.transfer.start`, `domain.provisioning`, `domain.setup`, `dns.status`
- `storage.read`
- `storage.write`
- `email.send`
- `blockchain.config.read`
- `blockchain.solana.sign.x402` (Cedros Pay access
  `cedros-pay:blockchain-x402-sign` only; policy-bound Vault signer)
- `blockchain.solana.sign.assistant-x402` (Cedros Pay access
  `cedros-pay:blockchain-assistant-x402-sign` only; isolated Site Assistant
  wallet and assistant/run-bound Vault policy; exact SPL mint, amount,
  decimals, merchant ATA, memo, and separate fee ceiling)
- `blockchain.solana.refund.x402` (Cedros Pay access
  `cedros-pay:blockchain-x402-refund` only; durable policy-bound operational
  wallet refund signing and broadcast)

The scope registry above is advisory naming, not strict intake validation.
Manifest intake validates only id namespacing/uniqueness and that each access
entry declares at least one non-empty scope — any scope string is accepted.
Runtime enforcement is `request.operation == declared scope`, plus the
storage/email verb mapping (`storage.read`/`storage.write` → get/put/delete,
`email.send` → send). For the AI kinds (`intelligence`, `imageGeneration`,
`voice`) the declared scope acts as a label only: the host checks the
*payload's* operation against the provider kind, which also permits
`StructuredOutput` under `intelligence` and `VideoGeneration` under
`imageGeneration`. Use the scopes above anyway so operator review stays
legible. `context.feature` is product attribution; `context.description` is
human-readable, redacted support copy.

Manifest `providerAccess[].tags[]` are default reporting tags for the access
entry. Runtime call tags may add narrower context but must remain redacted,
stable, and safe for usage/cost reporting. Do not put prompts, email subjects,
raw inputs, user identifiers, or other PII in either manifest or runtime tags.

## Host service ids (admin surface)

| Manifest/service id | SDK constant |
|---|---|
| `site-intelligence-provider` | `HOST_SERVICE_IDS.siteIntelligenceProvider` |
| `site-image-generation-provider` | `HOST_SERVICE_IDS.siteImageGenerationProvider` |
| `site-voice-provider` | `HOST_SERVICE_IDS.siteVoiceProvider` |
| `site-storage-provider` | `HOST_SERVICE_IDS.siteStorageProvider` |
| `site-email-provider` | `HOST_SERVICE_IDS.siteEmailProvider` |
| `site-blockchain-provider` | `HOST_SERVICE_IDS.siteBlockchainProvider` |

These constants are reserved naming: no current host publishes any
`site-*-provider` service into the admin host service bag (the default
`available_services` is empty, so a required `dependencies.services[]` entry on
them always blocks install) — use the wasm provider seam instead. These ids
are direct host-service ids, not automatic proof that a site's provider
settings are configured. For wasm provider calls, declare `providerAccess[]`
and let the provider seam fail closed at call time when the site provider is
unavailable. Only put a `site-*provider` id in `dependencies.services[]` when
the extension directly calls a published frontend/admin host service for that
provider. Do not add `site-storage-provider` or `site-email-provider` as hard
install/enable dependencies only because the package declares storage or email
`providerAccess[]`.

`domains` provider access is currently used by managed-domain runtime paths
that depend on `cedros-managed-api` or a host-coordinated sidecar/first-party
route. Do not invent `HOST_SERVICE_IDS.siteDomainsProvider`; use the published
managed API service dependency or document the host binding until Cedros
exposes a domain provider service constant.

## Published storage/email operation contract

| Provider | Manifest scope | Operation | Request payload | Success response |
|---|---|---|---|---|
| `storage` | `storage.write` | `put` | `{ "key": string, "content": "<base64>", "contentType"?: string }` | `{ "key": "extensions/{extensionId}/..." }` |
| `storage` | `storage.read` | `get` | `{ "key": string }` | `{ "key": "extensions/{extensionId}/...", "size": number, "content": "<base64>" }` |
| `storage` | `storage.write` | `delete` | `{ "key": string }` | `{ "key": "extensions/{extensionId}/...", "deleted": true }` |
| `storage` (Login only) | `storage.write` | `identity.avatar.replace` | `{ "userEntryKey", "expectedVersion", "content", "contentType" }` | `{ "status": "replaced", "key", "contentType", "version", "cleanupPending" }` |
| `storage` (Login only) | `storage.write` | `identity.avatar.delete` | `{ "userEntryKey", "expectedVersion" }` | `{ "status": "deleted", "version", "cleanupPending" }` |
| `email` | `email.send` | `send` | `{ "to": string, "subject": string, "htmlBody"?: string, "textBody"?: string }` | `{ "sent": true, "provider": string }` |

Provider host errors include stable `code` and `category` fields in addition
to the human `message`. Use `cedros.provider.not_configured` /
`provider_not_configured` to skip or queue optional email work, and
`cedros.provider.unavailable` / `provider_unavailable` when a provider family
is not routed on the host. Storage failures surface under category
`storage_failed`; a `get` on a missing key is one of those (not an empty
success) — treat it as "absent".

There is no presigned-URL operation and no public storage URL: serve stored
bytes through your own authenticated extension route. See the file-hosting
decision guide in [`07-wasm-server-backends.md`](07-wasm-server-backends.md).

## Published intelligence operation contract

Reached via `providers.execute(access-id, "intelligence", operation, payload-json)`.
This routes to the **server CMS AI gateway**
(`AiGateway::from_config(store.load_runtime_ai_config())`), not the
assistant-runtime registry. Request/response types are `AiExecutionRequest` /
`AiExecutionResponse` (source of truth: `server/src/ai/contracts.rs`; seam
`server/src/wasm_extension_providers.rs`).

> **Common wrong assumption:** the wasm `operation` argument selects the
> intelligence operation. **Actually:** for the AI kinds the `operation`
> argument is a *scope label only* — it is validated as non-empty and against
> your manifest `providerAccess[].scopes[]`, then discarded. The real operation
> comes from `payload.operation.kind`. There are exactly two valid intelligence
> kinds: `chat` and `structured_output` (`image`/`video` kinds belong to
> provider `imageGeneration`; `audio`/`speech` to `voice`).

`provider` and `model` remain required in `AiExecutionRequest`. Do not hard-code
them: first call
`providers.resolve-default(access-id, "intelligence", operation)`. That call
uses the same access-id/provider/scope enforcement and returns the current
host-selected `{ status, provider, model }` only when it is ready under site
policy. Copy the returned provider/model into `execute`; the gateway rechecks
them and still enforces model allowlists, capability checks, rate limits,
fallback policy, and usage logging.

The extension does not store or receive a provider API key. Host credentials
and secret references never cross the WIT boundary.
The host emits a structured provider-call audit record for successful, denied,
and invalid attempts with extension/access attribution and the stable error
code; request payloads and provider credentials are not logged in that record.

```rust
let defaults: serde_json::Value = serde_json::from_str(
    &providers::resolve_default(
        "my-ext/intelligence",
        "intelligence",
        "chat.generate",
    )?,
)?;
let payload = serde_json::json!({
    "provider": defaults["provider"],
    "model": defaults["model"],
    "operation": {
        "kind": "chat",
        "messages": [{
            "role": "user",
            "parts": [{ "kind": "text", "text": "Summarize this record." }]
        }]
    }
});
let response = providers::execute(
    "my-ext/intelligence",
    "intelligence",
    "chat.generate",
    &payload.to_string(),
)?;
```

| Manifest scope | `payload.operation.kind` | Request payload | Success response (`output`) |
|---|---|---|---|
| `chat.generate` | `chat` | `{ "provider": "openai\|anthropic\|gemini\|xai\|openrouter\|venice\|elevenlabs", "model": string, "operation": { "kind": "chat", "messages": AiMessage[] }, "tool_envelope"?: {…}, "metadata"?: {} }` | `{ "kind": "messages", "messages": AiMessage[] }` |
| `chat.generate` | `structured_output` | `{ …, "operation": { "kind": "structured_output", "messages": AiMessage[], "schema": <JSON Schema> } }` | `{ "kind": "structured", "value": <JSON> }` |

`AiMessage` = `{ "role": "system\|user\|assistant\|tool", "parts": AiMessagePart[] }`.
`AiMessagePart` kinds: `{ "kind": "text", "text": string }`,
`{ "kind": "json", "value": <JSON> }`,
`{ "kind": "asset", "media_type": string, "url": string }`.

The full `AiExecutionResponse` (fields are snake_case verbatim):

```json
{
  "provider": "anthropic",
  "model": "...",
  "output": { "kind": "messages", "messages": [/* AiMessage[] */] },
  "tool_calls": [ { "call_id": "...", "tool_name": "...", "arguments": {} } ],
  "usage": { "metrics": [ { "name": "...", "value": 0, "unit": "..." } ] },
  "finish_reason": "stop",
  "audit": {
    "timeout_secs": 30,
    "max_retries": 1,
    "rate_limit_per_minute": 60,
    "usage_logging_enabled": true
  }
}
```

> **Common wrong assumption:** the seam runs an in-process tool loop, so
> `tool_calls` are already resolved. **Actually:** the seam calls
> `gateway.execute(request, None)` — there is **no** host tool loop. Any
> `tool_calls` are returned verbatim; if you pass tools you run your own loop
> and feed `tool` messages back. Likewise, do not hard-code on
> `finish_reason == "stop"`: it is a raw provider passthrough
> (`Option<String>`) and varies by provider.

Timeout/retry come from the site `AiConfig` and are echoed in `audit`
(defaults shown): `timeout_secs` = 30 (`AI_TIMEOUT_SECS`), `max_retries` = 1
(`AI_MAX_RETRIES`; total attempts = retries + 1), `rate_limit_per_minute` = 60
(`AI_RATE_LIMIT_PER_MINUTE`). `InvalidRequest` and `RateLimited` are
**permanent** (no retry, no fallback). On a retryable failure the gateway may
**fall back to a different provider/model** — so always read
`provider`/`model` from the response, never assume they match what you sent.
The gateway also enforces model allowlisting and an input-size cap (default
200k chars).

Intelligence host-error mapping (branch on `code`/`category`, never `message`
text; source `server/src/wasm_extension_runtime.rs`):

| `code` | `category` | Cause |
|---|---|---|
| `cedros.provider.not_configured` | `provider_not_configured` | No ready host-selected intelligence default, or the requested provider is not configured. |
| `cedros.provider.denied` | `provider_denied` | Missing/incorrect `providerAccess[]` grant or scope, or a provider/model blocked by site policy or the model allowlist. |
| `cedros.provider.invalid_request` | `provider_invalid_request` | Unknown provider kind, empty operation, malformed AI payload, or operation-kind mismatch. |
| `cedros.provider.unavailable` | `provider_unavailable` | intelligence family not routed on the host |
| `cedros.request.rate_limited` | `rate_limited` | per-minute rate limit exceeded |
| `cedros.ai.execution_failed` | `downstream_failed` | gateway/provider execution failure after retries |

Example — manifest grant plus the call:

```jsonc
// manifest providerAccess[] entry
{
  "id": "my-ext/intelligence",
  "provider": "intelligence",
  "displayName": "Summaries",
  "description": "Summarize a record for the admin view.",
  "scopes": ["chat.generate"]
}
```

```jsonc
// Resolve first; response contains no secret fields.
// providers.resolve-default(
//   "my-ext/intelligence", "intelligence", "chat.generate"
// ) -> { "status": "configured", "provider": "anthropic", "model": "..." }
//
// Then providers.execute("my-ext/intelligence", "intelligence",
//   "chat.generate", payload)
{
  "provider": "anthropic",
  "model": "claude-3-5-sonnet",
  "operation": {
    "kind": "chat",
    "messages": [
      { "role": "user", "parts": [ { "kind": "text", "text": "Summarize: …" } ] }
    ]
  }
}
// response (AiExecutionResponse):
{
  "provider": "anthropic",
  "model": "claude-3-5-sonnet",
  "output": { "kind": "messages", "messages": [ /* assistant AiMessage */ ] },
  "tool_calls": [],
  "usage": { "metrics": [ /* … */ ] },
  "finish_reason": "stop",
  "audit": { "timeout_secs": 30, "max_retries": 1, "rate_limit_per_minute": 60, "usage_logging_enabled": true }
}
```

## Customer-funded intelligence

Hosts advertising `cedros-data:customer-intelligence-credentials-v1` accept an
optional `customerCredentialKey` on intelligence execution payloads. Extensions
must require that capability before using this field: older hosts do not provide
the funding-isolation guarantee. The key has the form
`intelligence-customer-<64 lowercase SHA-256 hex characters>`, derived from the
server-verified Login subject. Never accept a caller-selected customer or secret
reference. Verify the session, same-origin mutations, and stale-account guards
before saving, deleting, inspecting, or executing a customer's credentials.

Use the encrypted extension secret seam to store the strict JSON shape
`{"provider":"openai","model":"gpt-4.1-mini","apiKey":"..."}`. OpenAI,
Anthropic, and Gemini are supported. The host loads only the invoking extension's
secret from this site's store, requires the requested provider/model to match,
and uses the canonical provider endpoint. Missing or invalid credentials fail
closed. No secret belongs in workbook data, exports, status responses, logs,
manifests, bootstrap data, or browser storage.

Execution retains host provider/model allowlists, capability checks, input limits,
rate limits, and retries. It bypasses managed funding and all configured or
request-selected fallback chains; it never uses site keys or ChatGPT sessions.
Customer calls do not enter site-funded spend accounting. The ordinary
site-provider default and execution behavior remain unchanged when the field is
absent. Customer key settings are permitted specifically for this contract.

## Provider behavior requirements

- model/provider selection is site-configured unless Cedros publishes a
  provider override contract
- domain operations require managed API ownership, registrar/DNS credential
  isolation, idempotency, and human-review gates for purchases, transfers, and
  destructive DNS changes
- email sends require sender/reply-to/recipient/consent rules and idempotency
- storage writes require extension-owned path prefixes and idempotency keys
- provider calls need timeout, retry, and fallback states
- if timeout or retry defaults are not published by the provider contract,
  document them as host handoff expectations instead of inventing defaults
- cost/usage records must include extension id and access id; feature and tags
  are available only from host-native/CMS-proxy callers (the wasm seam carries
  no context fields)
- streaming is host-coordinated unless the provider contract marks it published

## Rules

- Do not require extension-owned provider API keys when a site provider exists.
- Do not hide provider calls behind generic network clients.
- Do not put prompts, raw email bodies, secrets, access tokens, or raw PII in
  provider call tags or descriptions.
- Keep `providerAccess[].id` namespaced with the extension id.
- Declare at least one scope for each provider access entry.
- Use admin pages only for extension-owned provider-dependent feature settings;
  do not add provider credential/configuration tabs or a separate extension
  settings-panel seam for host-owned provider setup.
- Fail closed when the site provider is unavailable or the extension lacks the
  required host service/capability.

## Acceptance checks

- Every provider call maps to a `providerAccess[]` entry or explicitly explains
  why it is host-internal.
- Observability can filter usage by `extensionId` and `accessId`. Filtering by
  `feature` and tags applies only to host-native/CMS-proxy callers — it is not
  satisfiable from wasm today, where `providers.execute` carries no context
  fields.
- Disabled providers produce useful extension UI/job errors without falling
  back to extension-owned secrets.
- Sensitive inputs are redacted from logs, analytics, and site-brain records.
- Retryable provider calls have idempotency or duplicate-send guards.

## Related host capabilities

The provider seam, the site-database seam
([`08-site-database.md`](08-site-database.md)), and the customer-tag seam
([`20-crm-tags-and-source-events.md`](20-crm-tags-and-source-events.md)) share
the same shape: a manifest declaration of intent, operator-reviewed scopes, and
a validated client that enforces those scopes at runtime with explicit
`extensionId` / `accessId` attribution (`feature` / `description` / `tags` from
host-native callers only). When designing a new capability that needs the same
review and audit affordances, look at those three first.

Next: [`11-jobs-and-scheduling.md`](11-jobs-and-scheduling.md).
