Services — AI and beyond

@civility/services calls credentialed services — AI models today — from a Civility app. A user can bring their own API key, route through the server, or use a host-provided bundle; your app code is the same in every case.

Protocol-level design (catalog shape, grant matching, credential encryption, egress policy): services spec. Full API: jsr.io/@civility/services/doc.


1. The three modes #

Every service invocation resolves at runtime to one of three execution modes — the app never picks, the platform does, based on the service catalog and the user’s grant:

| Mode | Where the call goes | Credential | | ————— | ––––––––––––––––––––––––– | –––––––––––––––––––––––––––– | | client-direct | Browser → provider directly (needs CORS). | User’s own key, never leaves device. | | server-proxy | Browser → your Civility server → provider. | User’s key, encrypted at rest, decrypted per-invocation. | | bundle | Browser → server → provider, using a host key. | Host-owned; metered against the user’s tier quota. |

A self-hosted server with the proxy disabled supports only client-direct. The hosted Civility service runs the proxy and offers bundle credentials. invoke fails with a structured services.no_supported_mode error when none of a service’s modes are available — surface that to the user.


2. Install #

deno add jsr:@civility/services

The AI helpers live at the /ai subpath (@civility/services/ai). generateStructured uses Zod schemas, so you’ll want @zod/zod too if you don’t already have it.


3. The quick path: generateText / generateStructured #

The /ai helpers wrap invoke; generateStructured parses the result against a Zod schema so you get a typed value back.

import { generateText } from '@civility/services/ai'

const apiKey = app.settings.value?.aiApiKey // the user's own key, from your settings UI
const oneLiner = await generateText({
  prompt: `Summarize this note in one sentence:\n\n${note.body}`,
  config: { apiKey, provider: 'claude' }, // provider defaults to 'claude'
})

Structured output — hand it a schema, get back a parsed object:

import { z } from '@zod/zod'
import { generateStructured } from '@civility/services/ai'

const Recipe = z.object({
  title: z.string(),
  ingredients: z.array(z.string()),
  steps: z.array(z.string()),
})

const recipe = await generateStructured({
  prompt: 'Extract a recipe from the following text.',
  userDescription: pastedText,
  schema: Recipe,
  config: { apiKey, provider: 'claude' },
})
// recipe is typed as z.infer<typeof Recipe> — already validated

AiConfig is { apiKey, provider?, model?, maxTokens? }; provider is 'claude' | 'openai' | 'gemini' (default 'claude'). If you make many calls, createClient(config) returns a key-bound { generateText, generateStructured } so you don’t repeat config.

That’s bring-your-own-key at its simplest: the key comes from your app’s settings and the call goes direct to the provider. For managed credentials, grants, and mode resolution, use Services.


4. The full path: the Services object #

Services gives you the catalog, mode resolution, managed credentials, and invoke for any registered service (not just AI).

import { Services } from '@civility/services'

const services = new Services({
  serverUrl: conn.url, // from cloud.restoreConnection()
  app: { id: APP_ID, token: conn.token },
})

const catalog = await services.catalog() // what this server offers
const mode = await services.resolveMode('ai.claude') // client-direct | server-proxy | bundle
const result = await services.invoke('ai.claude', {
  messages: [{ role: 'user', content: 'Hello' }],
})

ServicesConfig: serverUrl (required), env?, registry?, credentials?, app?: { id, token }, usage?, usageRetentionDays?, fetch?.

Pass usage: store.collection('_civility.usage') for local, E2E-encrypted metering: every invocation records an event attributed to the credential that fulfilled it (the per-credential picture the server can’t build). Events older than usageRetentionDays (default 90) prune automatically; read them via services.usage.list() / summarizeUsage(). The dashboard’s Credentials page reads the same collection.

services.catalog(force?): Promise<ServiceCatalogResponse>
services.resolveMode(service, grant?): Promise<ExecutionMode>
services.invoke<T>(service, input, opts?: { grant?, secret? }): Promise<T>
services.invokeStream<T>(service, input, opts?: { grant?, secret?, signal? }): AsyncIterable<T>
services.credentials.list() / create(input) / update(id, patch) / delete(id)

invoke<T>(service, input, opts?) takes an optional { grant, secret }. Pass secret to supply a credential inline for a one-off client-direct call; omit it to let the server resolve the user’s stored credential (proxy) or a host bundle.

invokeStream is the token-by-token variant for services that declare streaming (all the ai.* chat providers do). Chunks for the AI family are ChatChunk { text?, stopReason? }:

for await (
  const chunk of services.invokeStream<ChatChunk>('ai.claude', input)
) {
  if (chunk.text) render(chunk.text)
}

It degrades gracefully: when the service or host can’t stream, you get the complete output as a single chunk — same loop, no branching. (The /ai helpers offer the same thing as streamText({ prompt, config }), yielding plain strings.)

Managed credentials

Store the key through the platform instead of in your app’s settings document — encrypted at rest, reusable across modes:

await services.credentials.create({ service: 'ai.claude', secret: { apiKey } })
const creds = await services.credentials.list()
await services.credentials.update(id, { secret: { apiKey: rotated } })
await services.credentials.delete(id)

The dashboard also exposes these at /dashboard/services, so users can manage keys outside your app. Stored credentials feed the server-proxy mode without the app ever handling the raw key again.


5. Grants: declaring what your app needs #

Service access is a permission, granted by the user the same way data-scope permissions are. Declare the services your app uses in its manifest so they appear in the authorization popup alongside data permissions:

// manifest
{
  "id": "me.example.myapp",
  "services": ["ai.*"] // wildcards match: ai.* covers ai.claude, ai.openai, …
}

The user approves service access in the same grant popup that handles data scopes — there is no separate flow. invoke carries the resulting ServiceGrant, and wildcard matching is handled for you.


6. Host bundles and quotas #

When the server offers a bundle credential, users without their own key can still invoke the service — the server uses a host-owned key and meters usage against the user’s tier. This is transparent to the app; the only new failure is services.quota_exceeded (HTTP 429) when the monthly allowance is spent.

try {
  const out = await services.invoke('ai.claude', input)
} catch (e) {
  if (e.code === 'services.quota_exceeded') showUpgradePrompt()
  else if (e.code === 'services.no_supported_mode') showBringYourOwnKey()
  else throw e
}

Whether bundles and the proxy are available is a server decision — see CivServices setup. Design for client-direct + bring-your-own-key as the baseline, since a self-hosted server may have the proxy off.

Calling a provider directly with your own fetch also works, but then your app owns credential storage, CORS, and provider quirks. Prefer @civility/services for new work.


Next #

  • AI-powered recipe — free text → structured data, end-to-end.
  • Sync — services route through the same server your data syncs to.