Alchemy
Declare Keiki agents and channels as Alchemy resources, next to the infrastructure the agent runs on.
@onkeiki/alchemy provides Alchemy v2 resources and Effect layers for Keiki. An agent declared
in your stack is attached to channels by providing it to channel layers. alchemy deploy
creates or updates them, and alchemy destroy removes them.
bun add -d @onkeiki/alchemy alchemy effect @effect/platform-bunThe provider reads an org API key from KEIKI_API_KEY and, optionally, a base URL from
KEIKI_BASE_URL.
Agent on Cloudflare
KeikiCloudflare.Agent puts the agent on the external harness.
Keiki pushes each turn to <worker url>/keiki, where the Worker runs the turn with
createKeikiHandler.
import * as from '@alchemy.run/cloudflare'
import * as from '@onkeiki/alchemy'
import * as from '@onkeiki/alchemy/cloudflare'
import * as from 'alchemy'
import * as from 'effect/Effect'
import * as from 'effect/Layer'
export const = .Worker('Api', { : './src/worker.ts' })
export const = .Agent('Support', { : })
export const = .IMessage('Line', { : 1 }).pipe(.())
export default .Stack(
'Acme',
{
: .(.providers(), .providers()),
: .state(),
},
.(function* () {
const = yield* .KeikiAgent
return { : .id }
}).(.(.(, ))),
)Set path to serve turns from a route other than /keiki. The Worker URL is read when the
stack deploys, so a Worker that moves updates the agent's dispatch URL on the next deploy.
Agents on other hosts
Keiki.Agent accepts any public HTTPS dispatchUrl, or inbox: true to queue turns for
polling. A new host adapter only has to produce that URL, as KeikiCloudflare.Agent does.
Keiki.HostedAgent instead has Keiki run the model loop itself:
const = Keiki.Agent('Support', { : 'https://agent.acme.dev/keiki' })
const = Keiki.HostedAgent('Concierge', {
: 'anthropic/claude-sonnet-4.6',
: 'You are the front desk for Acme.',
})Each agent is a partner deployment. Its external_ref defaults to
<stack>-<stage>-<id>, so every stage gets its own agent. Pass externalRef to adopt an existing
deployment.
Channels
| Layer | Channel | Props |
|---|---|---|
IMessage | imessage | optional lines (1–10) |
Telegram | telegram | bot_token |
Slack | slack | bot_token, signing_secret, optional client_id, client_secret |
WhatsApp | whatsapp | access_token, phone_number_id, app_secret, verify_token, optional waba_id |
Email | agentmail | region, mode, optional inbox_id, username, domain |
Api | api | optional passthrough |
A2A | a2a | none |
Props follow the partner deployment channel spec. Pass ref to
attach several channels of one type to the same agent (not supported for Email). Changing a channel's credentials
replaces it, because a deploy keeps an existing channel's credentials. A replacement detaches the old
channel before attaching the new one. Raising IMessage's lines
buys the missing lines in place; lowering it releases none.
Destroying a channel calls the single-channel detach endpoint. An IMessage channel releases
every line the agent holds, so declare one per agent. To keep a line, set Alchemy's retain
removal policy on the resource. Destroying the agent deletes the deployment, its channels and
its lines.
If an iMessage line needs payment, the channel's status is needs_payment and its
checkoutUrl holds the checkout link. Deploy again after paying.
An Alchemy example deploys a Cloudflare Worker agent with an iMessage line:
cd integrations/alchemy
OPENROUTER_API_KEY=... KEIKI_API_KEY=... bun run deploy:example
bun run destroy:example