docs/plugins/sdk-migration.md
OpenClaw replaced a broad backwards-compatibility layer with a modern plugin architecture built from small, focused imports. If your plugin predates that change, this guide gets it onto the current contracts.
Two wide-open import surfaces used to let plugins reach almost anything from a single entry point:
openclaw/plugin-sdk/compat - re-exported dozens of helpers to keep
older hook-based plugins working while the new architecture was built.openclaw/plugin-sdk/infra-runtime - a broad barrel mixing system
events, heartbeat state, delivery queues, fetch/proxy helpers, file helpers,
approval types, and unrelated utilities.openclaw/plugin-sdk/config-runtime - a broad config barrel still
carrying deprecated direct load/write helpers during the migration window.openclaw/extension-api - a bridge giving plugins direct access to
host-side helpers like the embedded agent runner.api.registerEmbeddedExtensionFactory(...) - a removed embedded-runner-only
hook that observed embedded-runner events such as tool_result. Use agent
tool-result middleware instead (see Migrate embedded tool-result extensions
to middleware).These surfaces are deprecated: they still work, but new plugins must not
use them, and existing plugins should migrate before the next major release
removes them. registerEmbeddedExtensionFactory has already been removed;
legacy registrations no longer load.
OpenClaw does not remove or reinterpret documented plugin behavior in the same change that introduces a replacement. Breaking contract changes go through a compatibility adapter, diagnostics, docs, and a deprecation window first. That applies to SDK imports, manifest fields, setup APIs, hooks, and runtime registration behavior.
Each openclaw/plugin-sdk/<subpath> is now a small, self-contained module with
a documented contract.
Legacy provider convenience seams for bundled channels are gone too -
channel-branded helper shortcuts were private mono-repo conveniences, not
stable plugin contracts. Use narrow generic SDK subpaths instead. Inside the
bundled plugin workspace, keep provider-owned helpers in that plugin's own
api.ts or runtime-api.ts:
api.ts /
contract-api.ts seam.api.ts.api.ts.External-plugin compatibility work follows this order:
If a manifest field is still accepted, keep using it until docs and diagnostics say otherwise. New code should prefer the documented replacement; existing plugins should not break during ordinary minor releases.
Audit the current migration queue with pnpm plugins:boundary-report:
| Flag | Effect |
|---|---|
--summary (or pnpm plugins:boundary-report:summary) | Compact counts instead of full detail. |
--json | Machine-readable report. |
--owner <id> | Filter to one plugin or compatibility owner. |
--fail-on-cross-owner | Exit non-zero on cross-owner reserved SDK imports. |
--fail-on-eligible-compat | Exit non-zero when a deprecated compat record's removeAfter date has passed. |
--fail-on-unclassified-unused-reserved | Exit non-zero on unused reserved SDK shims. |
pnpm plugins:boundary-report:ci runs with all three fail flags. Each
compatibility record has an explicit removeAfter date (not a vague "next
major release") - the report groups deprecated records by that date, counts
local code/doc references, surfaces cross-owner reserved SDK imports, and
summarizes the private memory-host SDK bridge. Reserved SDK subpaths must have
tracked owner usage; unused reserved exports should be removed from the public
SDK.
Config writes go through the transactional helper with an explicit
after-write policy:
```typescript
await api.runtime.config.mutateConfigFile({
afterWrite: { mode: "auto" },
mutate(draft) {
draft.plugins ??= {};
},
});
```
Use `afterWrite: { mode: "restart", reason: "..." }` when the change needs
a clean gateway restart, and `afterWrite: { mode: "none", reason: "..." }`
only when the caller owns the follow-up and deliberately suppresses the
reload planner. Mutation results include a typed `followUp` summary for
tests and logging; the gateway remains responsible for applying or
scheduling the restart.
`loadConfig` and `writeConfigFile` remain as deprecated compatibility
helpers for external plugins and warn once with the
`runtime-config-load-write` compatibility code. Bundled plugins and repo
runtime code are guarded by `pnpm check:deprecated-api-usage` and
`pnpm check:no-runtime-action-load-config`: new production plugin usage
fails outright, direct config writes fail, gateway server methods must use
the request runtime snapshot, runtime channel send/action/client helpers
must receive config from their boundary, and long-lived runtime modules
allow zero ambient `loadConfig()` calls.
New plugin code should avoid the broad `openclaw/plugin-sdk/config-runtime`
barrel. Use the narrow subpath for the job:
| Need | Import |
| --- | --- |
| Config types such as `OpenClawConfig` | `openclaw/plugin-sdk/config-contracts` |
| Already-loaded config assertions and plugin-entry config lookup | `openclaw/plugin-sdk/plugin-config-runtime` |
| Current runtime snapshot reads | `openclaw/plugin-sdk/runtime-config-snapshot` |
| Config writes | `openclaw/plugin-sdk/config-mutation` |
| Session store helpers | `openclaw/plugin-sdk/session-store-runtime` |
| Markdown table config | `openclaw/plugin-sdk/markdown-table-runtime` |
| Group policy runtime helpers | `openclaw/plugin-sdk/runtime-group-policy` |
| Secret input resolution | `openclaw/plugin-sdk/secret-input-runtime` |
| Model/session overrides | `openclaw/plugin-sdk/model-session-runtime` |
Bundled plugins and their tests are scanner-guarded against the broad
barrel so imports and mocks stay local to the behavior they need. The
barrel still exists for external compatibility, but new code should not
depend on it.
```typescript
// OpenClaw and Codex runtime dynamic tools
api.registerAgentToolResultMiddleware(async (event) => {
return compactToolResult(event);
}, {
runtimes: ["openclaw", "codex"],
});
```
Update the plugin manifest at the same time:
```json
{
"contracts": {
"agentToolResultMiddleware": ["openclaw", "codex"]
}
}
```
Installed plugins can also register tool-result middleware when explicitly
enabled and every targeted runtime is declared in
`contracts.agentToolResultMiddleware`. Undeclared installed middleware
registrations are rejected.
- Replace `approvalCapability.handler.loadRuntime(...)` with
`approvalCapability.nativeRuntime`.
- Move approval-specific auth/delivery off legacy `plugin.auth` /
`plugin.approvals` wiring and onto `approvalCapability`.
- `ChannelPlugin.approvals` has been removed from the public
channel-plugin contract; move delivery/native/render fields onto
`approvalCapability`.
- `plugin.auth` remains for channel login/logout flows only; core no
longer reads approval auth hooks there.
- Register channel-owned runtime objects (clients, tokens, Bolt apps)
through `openclaw/plugin-sdk/channel-runtime-context`.
- Do not send plugin-owned reroute notices from native approval handlers;
core owns routed-elsewhere notices from actual delivery results.
- When passing `channelRuntime` into `createChannelManager(...)`, provide a
real `createPluginRuntime().channel` surface - partial stubs are
rejected.
See [Channel Plugins](/plugins/sdk-channel-plugins) for the current
approval capability layout.
```typescript
// Before
const program = applyWindowsSpawnProgramPolicy({ candidate });
// After
const program = applyWindowsSpawnProgramPolicy({
candidate,
// Only set this for trusted compatibility callers that intentionally
// accept shell-mediated fallback.
allowShellFallback: true,
});
```
If your caller does not intentionally rely on shell fallback, do not set
`allowShellFallback` and handle the thrown error instead.
```typescript
// Before (deprecated backwards-compatibility layer)
import {
createChannelReplyPipeline,
createPluginRuntimeStore,
resolveControlCommandGate,
} from "openclaw/plugin-sdk/compat";
// After (modern focused imports)
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
```
For host-side helpers, use the injected plugin runtime instead of
importing directly:
```typescript
// Before (deprecated extension-api bridge)
import { runEmbeddedAgent } from "openclaw/extension-api";
const result = await runEmbeddedAgent({ sessionId, prompt });
// After (injected runtime)
const result = await api.runtime.agent.runEmbeddedAgent({ sessionId, prompt });
```
Same pattern for other legacy bridge helpers:
| Old import | Modern equivalent |
| --- | --- |
| `resolveAgentDir` | `api.runtime.agent.resolveAgentDir` |
| `resolveAgentWorkspaceDir` | `api.runtime.agent.resolveAgentWorkspaceDir` |
| `resolveAgentIdentity` | `api.runtime.agent.resolveAgentIdentity` |
| `resolveThinkingDefault` | `api.runtime.agent.resolveThinkingDefault` |
| `resolveAgentTimeoutMs` | `api.runtime.agent.resolveAgentTimeoutMs` |
| `ensureAgentWorkspace` | `api.runtime.agent.ensureAgentWorkspace` |
| session store helpers | `api.runtime.agent.session.*` |
| Need | Import |
| --- | --- |
| System event queue helpers | `openclaw/plugin-sdk/system-event-runtime` |
| Heartbeat wake, event, and visibility helpers | `openclaw/plugin-sdk/heartbeat-runtime` |
| Pending delivery queue drain | `openclaw/plugin-sdk/delivery-queue-runtime` |
| Channel activity telemetry | `openclaw/plugin-sdk/channel-activity-runtime` |
| In-memory and persistent-backed dedupe caches | `openclaw/plugin-sdk/dedupe-runtime` |
| Safe local-file/media path helpers | `openclaw/plugin-sdk/file-access-runtime` |
| Dispatcher-aware fetch | `openclaw/plugin-sdk/runtime-fetch` |
| Proxy and guarded fetch helpers | `openclaw/plugin-sdk/fetch-runtime` |
| SSRF dispatcher policy types | `openclaw/plugin-sdk/ssrf-dispatcher` |
| Approval request/resolution types | `openclaw/plugin-sdk/approval-runtime` |
| Approval reply payload and command helpers | `openclaw/plugin-sdk/approval-reply-runtime` |
| Error formatting helpers | `openclaw/plugin-sdk/error-runtime` |
| Transport readiness waits | `openclaw/plugin-sdk/transport-ready-runtime` |
| Secure token helpers | `openclaw/plugin-sdk/secure-random-runtime` |
| Bounded async task concurrency | `openclaw/plugin-sdk/concurrency-runtime` |
| Numeric coercion | `openclaw/plugin-sdk/number-runtime` |
| Process-local async lock | `openclaw/plugin-sdk/async-lock-runtime` |
| File locks | `openclaw/plugin-sdk/file-lock` |
Bundled plugins are scanner-guarded against `infra-runtime`, so repo code
cannot regress to the broad barrel.
| Old helper | Modern helper |
| --- | --- |
| `channelRouteIdentityKey(...)` | `channelRouteDedupeKey(...)` |
| `channelRouteKey(...)` | `channelRouteCompactKey(...)` |
| `ComparableChannelTarget` | `ChannelRouteParsedTarget` |
| `comparableChannelTargetsMatch(...)` | `channelRouteTargetsMatchExact(...)` |
| `comparableChannelTargetsShareRoute(...)` | `channelRouteTargetsShareConversation(...)` |
The modern route helpers normalize `{ channel, to, accountId, threadId }`
consistently across native approvals, reply suppression, inbound dedupe,
cron delivery, and session routing.
Do not add new uses of `ChannelMessagingAdapter.parseExplicitTarget`, the
parser-backed loaded-route helpers (`parseExplicitTargetForLoadedChannel`,
`resolveRouteTargetForLoadedChannel`), or
`resolveChannelRouteTargetWithParser(...)` from `plugin-sdk/channel-route` -
those are deprecated and remain only for older plugins. New channel
plugins should use `messaging.targetResolver.resolveTarget(...)` for
target-id normalization and directory-miss fallback,
`messaging.inferTargetChatType(...)` when core needs an early peer kind,
and `messaging.resolveOutboundSessionRoute(...)` for provider-native
session and thread identity.
This table is the common migration subset, not the full SDK surface. The
compiler entrypoint inventory lives in scripts/lib/plugin-sdk-entrypoints.json;
package exports are generated from the public subset.
Reserved bundled-plugin helper seams have been retired from the public SDK
export map except for explicitly documented compatibility facades such as the
deprecated plugin-sdk/discord shim retained for external plugins that still
import the published @openclaw/discord package directly. Owner-specific
helpers live inside the owning plugin package; shared host behavior moves
through generic SDK contracts such as plugin-sdk/gateway-runtime,
plugin-sdk/security-runtime, and plugin-sdk/plugin-config-runtime.
Use the narrowest import that matches the job. If you cannot find an export,
check the source at src/plugin-sdk/ or ask maintainers which generic
contract should own it.
Narrower deprecations across the plugin SDK, provider contract, runtime surface, and manifest. Each still works today but will be removed in a future major release. Every entry maps the old API to its canonical replacement.
<AccordionGroup> <Accordion title="command-auth help builders -> command-status"> **Old (`openclaw/plugin-sdk/command-auth`)**: `buildCommandsMessage`, `buildCommandsMessagePaginated`, `buildHelpMessage`.**New (`openclaw/plugin-sdk/command-status`)**: same signatures, same
exports - just imported from the narrower subpath. `command-auth`
re-exports them as compat stubs.
```typescript
// Before
import { buildHelpMessage } from "openclaw/plugin-sdk/command-auth";
// After
import { buildHelpMessage } from "openclaw/plugin-sdk/command-status";
```
**New**: `resolveInboundMentionDecision({ facts, policy })` - one decision
object instead of two split call shapes.
Adopted across Discord, iMessage, Matrix, MS Teams, QQBot, Signal,
Telegram, WhatsApp, and Zalo. Slack's own `app_mention` event model does
not use this helper.
`channelActions*` helpers in `openclaw/plugin-sdk/channel-actions` are
deprecated alongside raw "actions" channel exports. Expose capabilities
through the semantic `presentation` surface instead - channel plugins
declare what they render (cards, buttons, selects) rather than which raw
action names they accept.
**New**: implement `createTool(...)` directly on the provider plugin.
OpenClaw no longer needs the SDK helper to register the tool wrapper.
**New**: `BodyForAgent` plus structured user-context blocks. Channel
plugins attach routing metadata (thread, topic, reply-to, reactions) as
typed fields instead of concatenating them into a prompt string. The
`formatAgentEnvelope(...)` helper is still supported for synthesized
assistant-facing envelopes, but inbound plaintext envelopes are on the way
out.
Affected areas: `inbound_claim`, `message_received`, and any custom
channel plugin that post-processed the old envelope text.
**New**: `api.on("gateway_stop", handler)`. Same shutdown cleanup
contract; only the hook name changes.
```typescript
// Before
api.on("deactivate", async (event, ctx) => {
await stopPluginService(ctx);
});
// After
api.on("gateway_stop", async (event, ctx) => {
await stopPluginService(ctx);
});
```
`deactivate` remains wired as a deprecated compatibility alias until it is
removed after 2026-08-16.
**New**: let core prepare `thread: true` subagent bindings through the
channel session-binding adapter. Use `api.on("subagent_spawned", handler)`
only for post-launch observation.
```typescript
// Before
api.on("subagent_spawning", async () => ({
status: "ok",
threadBindingReady: true,
deliveryOrigin: { channel: "discord", to: "channel:123", threadId: "456" },
}));
// After
api.on("subagent_spawned", async (event) => {
await observeSubagentLaunch(event);
});
```
`subagent_spawning`, `PluginHookSubagentSpawningEvent`,
`PluginHookSubagentSpawningResult`, and
`SubagentLifecycleHookRunner.runSubagentSpawning(...)` remain only as
deprecated compatibility surfaces while external plugins migrate, removed
after 2026-08-30.
| Old alias | New type |
| ------------------------- | ------------------------- |
| `ProviderDiscoveryOrder` | `ProviderCatalogOrder` |
| `ProviderDiscoveryContext`| `ProviderCatalogContext` |
| `ProviderDiscoveryResult` | `ProviderCatalogResult` |
| `ProviderPluginDiscovery` | `ProviderPluginCatalog` |
Plus the legacy `ProviderCapabilities` static bag - provider plugins
should use explicit provider hooks such as `buildReplayPolicy`,
`normalizeToolSchemas`, and `wrapStreamFn` rather than a static object.
**New**: a single `resolveThinkingProfile(ctx)` that returns a
`ProviderThinkingProfile` with the canonical `id`, optional `label`, and a
ranked level list. OpenClaw downgrades stale stored values by profile rank
automatically.
The context includes `provider`, `modelId`, optional merged `reasoning`,
and optional merged model `compat` facts. Provider plugins can use those
catalog facts to expose a model-specific profile only when the configured
request contract supports it.
Implement one hook instead of three. The legacy hooks keep working during
the deprecation window but are not composed with the profile result.
**New**: declare `contracts.externalAuthProviders` in the plugin manifest
**and** implement `resolveExternalAuthProfiles(...)`.
```json
{
"contracts": {
"externalAuthProviders": ["anthropic", "openai"]
}
}
```
**New**: mirror the same env-var lookup into `setup.providers[].envVars`
on the manifest. This consolidates setup/status env metadata in one place
and avoids booting the plugin runtime just to answer env-var lookups.
`providerAuthEnvVars` remains supported through a compatibility adapter
until the deprecation window closes.
**New**: one call on the memory-state API -
`registerMemoryCapability(pluginId, { promptBuilder, flushPlanResolver, runtime })`.
Same slots, single registration call. Additive prompt and corpus helpers
(`registerMemoryPromptSupplement`, `registerMemoryCorpusSupplement`) are
not affected.
**New**: `api.registerEmbeddingProvider(...)` plus
`contracts.embeddingProviders`.
The generic embedding provider contract is reusable outside memory and is
the supported path for new providers. The memory-specific registration API
remains wired as deprecated compatibility while existing providers
migrate. Plugin inspection reports non-bundled usage as compatibility
debt.
| Old | New |
| ----------------------------- | ------------------------------- |
| `SubagentReadSessionParams` | `SubagentGetSessionMessagesParams` |
| `SubagentReadSessionResult` | `SubagentGetSessionMessagesResult` |
The runtime method `readSession` is deprecated in favor of
`getSessionMessages`. Same signature; the old method calls through to the
new one.
**New**: `runtime.tasks.managedFlows` keeps the managed TaskFlow mutation
runtime for plugins that create, update, cancel, or run child tasks from a
flow. Use `runtime.tasks.flows` when the plugin only needs DTO-based
reads.
```typescript
// Before
const flow = api.runtime.tasks.flow.fromToolContext(ctx);
// After
const flow = api.runtime.tasks.managedFlows.fromToolContext(ctx);
```
Removed after 2026-07-26.
```typescript
// Before
import type { OpenClawSchemaType } from "openclaw/plugin-sdk";
// After
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-schema";
```
Realtime voice, telephony, meeting, and browser Talk code shares one Talk
session controller exported by openclaw/plugin-sdk/realtime-voice. The
controller owns the common Talk event envelope, active turn state, capture
state, output-audio state, recent event history, and stale-turn rejection.
Provider plugins own vendor-specific realtime sessions; surface plugins own
capture, playback, telephony, and meeting quirks.
All bundled surfaces run on the shared controller: browser relay,
managed-room handoff, voice-call realtime, voice-call streaming STT, Google
Meet realtime, and native push-to-talk. Gateway advertises one live Talk event
channel in hello-ok.features.events: talk.event.
New code should not call createTalkEventSequencer(...) directly unless
implementing a low-level adapter or test fixture. Use the shared controller so
turn-scoped events cannot be emitted without a turn id, stale turnEnd /
turnCancel calls cannot clear a newer active turn, and output-audio
lifecycle events stay consistent across telephony, meetings, browser relay,
managed-room handoff, and native Talk clients.
The public API shape:
// Gateway-owned Talk session API.
await gateway.request("talk.session.create", {
mode: "realtime",
transport: "gateway-relay",
brain: "agent-consult",
sessionKey: "main",
});
await gateway.request("talk.session.appendAudio", { sessionId, audioBase64 });
await gateway.request("talk.session.cancelOutput", { sessionId, reason: "barge-in" });
await gateway.request("talk.session.submitToolResult", {
sessionId,
callId,
result: { status: "working" },
options: { willContinue: true },
});
await gateway.request("talk.session.submitToolResult", {
sessionId,
callId,
result: { status: "already_delivered" },
options: { suppressResponse: true },
});
await gateway.request("talk.session.submitToolResult", { sessionId, callId, result });
await gateway.request("talk.session.close", { sessionId });
// Client-owned provider session API.
await gateway.request("talk.client.create", {
mode: "realtime",
transport: "webrtc",
brain: "agent-consult",
sessionKey: "main",
});
await gateway.request("talk.client.toolCall", { sessionKey, callId, name, args });
await gateway.request("talk.client.steer", { sessionKey, text, mode: "steer" });
Browser-owned WebRTC/provider-websocket sessions use talk.client.create,
because the browser owns provider negotiation and media transport while the
Gateway owns credentials, instructions, and tool policy. talk.session.* is
the common Gateway-managed surface for gateway-relay realtime, gateway-relay
transcription, and managed-room native STT/TTS sessions.
Legacy configs that place realtime selectors beside talk.provider /
talk.providers should be repaired with openclaw doctor --fix; runtime Talk
does not reinterpret speech/TTS provider config as realtime provider config.
The supported talk.session.create combinations are intentionally small:
| Mode | Transport | Brain | Owner | Notes |
|---|---|---|---|---|
realtime | gateway-relay | agent-consult | Gateway | Full-duplex provider audio bridged through the Gateway; tool calls route through the agent-consult tool. |
transcription | gateway-relay | none | Gateway | Streaming STT only; callers send input audio and receive transcript events. |
stt-tts | managed-room | agent-consult | Native/client room | Push-to-talk and walkie-talkie style rooms where the client owns capture/playback and the Gateway owns turn state. |
stt-tts | managed-room | direct-tools | Native/client room | Admin-only room mode for trusted first-party surfaces that execute Gateway tool actions directly. |
Method map for readers migrating from the older talk.realtime.* /
talk.transcription.* / talk.handoff.* families (all removed):
| Old | New |
|---|---|
talk.realtime.session | talk.client.create |
talk.realtime.toolCall | talk.client.toolCall |
talk.realtime.relayAudio | talk.session.appendAudio |
talk.realtime.relayCancel | talk.session.cancelOutput or talk.session.cancelTurn |
talk.realtime.relayToolResult | talk.session.submitToolResult |
talk.realtime.relayStop | talk.session.close |
talk.transcription.session | talk.session.create({ mode: "transcription" }) |
talk.transcription.relayAudio | talk.session.appendAudio |
talk.transcription.relayCancel | talk.session.cancelTurn |
talk.transcription.relayStop | talk.session.close |
talk.handoff.create | talk.session.create({ transport: "managed-room" }) |
talk.handoff.join | talk.session.join |
talk.handoff.revoke | talk.session.close |
The unified control vocabulary is also deliberately narrow:
| Method | Applies to | Contract |
|---|---|---|
talk.session.appendAudio | realtime/gateway-relay, transcription/gateway-relay | Append a base64 PCM audio chunk to the provider session owned by the same Gateway connection. |
talk.session.startTurn | stt-tts/managed-room | Start a managed-room user turn. |
talk.session.endTurn | stt-tts/managed-room | End the active turn after stale-turn validation. |
talk.session.cancelTurn | all Gateway-owned sessions | Cancel active capture/provider/agent/TTS work for a turn. |
talk.session.cancelOutput | realtime/gateway-relay | Stop assistant audio output without necessarily ending the user turn. |
talk.session.submitToolResult | realtime/gateway-relay | Complete a provider tool call emitted by the relay; pass options.willContinue for interim output or options.suppressResponse to satisfy the call without another assistant response. |
talk.session.steer | agent-backed Talk sessions | Send spoken status, steer, cancel, or followup control to the active embedded run resolved from the Talk session. |
talk.session.close | all unified sessions | Stop relay sessions or revoke managed-room state, then forget the unified session id. |
Do not introduce provider or platform special cases in core to make this work. Core owns Talk session semantics. Provider plugins own vendor session setup. Voice-call and Google Meet own telephony/meeting adapters. Browser and native apps own device capture/playback UX.
| When | What happens |
|---|---|
| Now | Deprecated surfaces emit runtime warnings. |
Each compat record's removeAfter date | That specific surface is eligible for removal; pnpm plugins:boundary-report --fail-on-eligible-compat fails CI once the date passes. |
| Next major release | Any surfaces still not migrated are removed; plugins still using them will fail. |
All core plugins have already migrated. External plugins should migrate
before the next major release. Run pnpm plugins:boundary-report to see which
compat records are due soonest for the surfaces your plugin uses.
OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run
OPENCLAW_SUPPRESS_EXTENSION_API_WARNING=1 openclaw gateway run
This is a temporary escape hatch, not a permanent solution.