Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Highlights

- Added an optional inbound audio transcription preprocessor so bound conversations can convert staged voice/audio attachments into normal text turn input before forwarding the turn into Codex. The plugin stays transport-agnostic by delegating transcription to a configurable local command that prints transcript text to stdout.

### Docs

- Documented the new `inboundAudioTranscription` plugin config and clarified the media bridge notes around staged inbound audio handling.

## v0.6.0 - 2026-04-03

### Highlights
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,39 @@ The plugin schema in [`openclaw.plugin.json`](./openclaw.plugin.json) supports:
- `defaultWorkspaceDir`: fallback workspace for unbound actions
- `defaultModel`: model used when a new thread starts without an explicit selection
- `defaultServiceTier`: default service tier for new turns
- `inboundAudioTranscription`: optional preprocessor for inbound audio/voice attachments before they are forwarded into Codex

### Optional inbound audio transcription

If your chat surface provides inbound audio files as local paths or media metadata, this plugin can transcribe them before forwarding the turn to Codex. This keeps the plugin transport-agnostic: Codex still receives normal text input, while transcription is delegated to any local command you choose.

Example config using an existing local script:

```json
{
"inboundAudioTranscription": {
"enabled": true,
"command": "/root/.openclaw/workspace/scripts/local-stt-transcribe.sh",
"args": ["{path}"],
"timeoutMs": 20000
}
}
```

Behavior:

- audio-only inbound messages become transcript text
- caption + audio keeps the caption and adds a labeled transcript block
- the command should print the transcript to stdout
- if stdout is JSON, `.text` or `.transcript` is used automatically

Argument placeholders supported in `args`:

- `{path}`
- `{mimeType}`
- `{fileName}`

If `{path}` is omitted from `args`, the plugin appends the media path automatically.

## Developer Workflow With A Local OpenClaw Checkout

Expand Down
40 changes: 38 additions & 2 deletions docs/specs/MEDIA.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ This document captures the current state of media handling relevant to this plug
- how Codex app-server accepts image input
- what this plugin currently sends
- what OpenClaw currently exposes to plugins
- the gap for inbound media
- the remaining gap for richer inbound media
- the staged-audio transcription bridge this plugin now supports
- a recommended bridge design for future implementation

This is a spec/notes document only. It does not imply that inbound media support has already been implemented here.
Expand All @@ -15,9 +16,11 @@ This is a spec/notes document only. It does not imply that inbound media support
- Codex app-server already supports multimodal turn input via `UserInput`.
- The supported image-shaped input items are remote/data URL images and local filesystem images.
- This plugin now supports mixed text + image turn input and forwards inbound image media into Codex when OpenClaw provides a staged media path or URL.
- This plugin can also transcribe staged inbound audio/voice attachments into plain text turn input when a local transcription command is configured.
- OpenClaw’s plugin SDK already supports outbound attachments from a plugin via `mediaUrl` and `mediaUrls`.
- OpenClaw’s plugin SDK still does not model inbound attachments as a first-class typed field on command or `inbound_claim` events.
- In practice, current `inbound_claim` hook metadata already carries `mediaPath` / `mediaType`, which is enough for this plugin to forward a staged inbound image.
- The same staged inbound path is also enough to transcribe audio before Codex sees the turn, as long as the plugin can execute an external transcription command against the staged file.
- The cleanest future bridge is: OpenClaw stages inbound files locally, then this plugin maps image paths to Codex `localImage` items.

## Codex App-Server Input Model
Expand Down Expand Up @@ -177,8 +180,41 @@ That means:
- text-only turns still work as before
- mixed text + image turns can be forwarded into Codex
- image-only inbound turns can be forwarded into Codex
- audio-only inbound turns can be converted into transcript text before the turn starts when `inboundAudioTranscription` is configured
- mixed caption + audio inbound turns can keep the original text and append a labeled transcript block
- staged text attachments such as `.txt`, `.md`, `.json`, `.yaml`, and `.yml` can be read and forwarded as additional `text` items
- unsupported binary non-image inbound media is still ignored for now
- unsupported binary non-image inbound media is still ignored for now unless a future bridge teaches the plugin how to reinterpret it

## Inbound Audio Transcription Bridge

The plugin does not send raw audio into Codex. Instead, it can optionally reinterpret staged audio files as text by invoking a configurable local command.

Configuration shape:

```json
{
"inboundAudioTranscription": {
"enabled": true,
"command": "/path/to/transcribe",
"args": ["{path}"],
"timeoutMs": 20000
}
}
```

Behavior:

- The command receives the staged media path either through an explicit `{path}` placeholder or as an appended trailing argument.
- Optional placeholders `{mimeType}` and `{fileName}` are available for wrappers that need them.
- The command should print the transcript to stdout.
- If stdout is JSON, the plugin uses `.text` first and then `.transcript`.
- On transcription failure or timeout, the plugin logs the failure and falls back to the previous behavior instead of crashing the inbound turn.

This keeps the bridge generic:

- no hard dependency on a specific speech-to-text engine
- no plugin-side audio decoding logic
- no transport-specific behavior baked into the Codex turn layer

## OpenClaw Plugin SDK: Outbound Media

Expand Down
27 changes: 27 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@
},
"defaultServiceTier": {
"type": "string"
},
"inboundAudioTranscription": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"command": {
"type": "string"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
},
"timeoutMs": {
"type": "number",
"minimum": 100
}
}
}
}
},
Expand Down Expand Up @@ -100,6 +122,11 @@
"defaultServiceTier": {
"label": "Default Service Tier",
"advanced": true
},
"inboundAudioTranscription": {
"label": "Inbound Audio Transcription",
"advanced": true,
"help": "Optional preprocessor for inbound audio/voice attachments. The command should print the transcript to stdout. Use {path}, {mimeType}, and {fileName} placeholders in args when needed."
}
}
}
24 changes: 23 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { PluginSettings } from "./types.js";
import type {
EndpointSettings,
InboundAudioTranscriptionSettings,
PluginSettings,
} from "./types.js";
import {
DEFAULT_REQUEST_TIMEOUT_MS,
} from "./types.js";
Expand Down Expand Up @@ -56,6 +60,23 @@ function readNumber(
return fallback;
}

function resolveInboundAudioTranscription(
record: Record<string, unknown>,
): InboundAudioTranscriptionSettings | undefined {
const nested = asRecord(record.inboundAudioTranscription);
const legacy = asRecord(record.audioTranscription);
const source = Object.keys(nested).length > 0 ? nested : legacy;
if (Object.keys(source).length === 0) {
return undefined;
}
return {
enabled: source.enabled !== false,
command: readString(source, "command"),
args: readStringArray(source, "args"),
timeoutMs: readNumber(source, "timeoutMs", 20_000, 100),
};
}

export function resolvePluginSettings(rawConfig: unknown): PluginSettings {
const record = asRecord(rawConfig);
const transport = record.transport === "websocket" ? "websocket" : "stdio";
Expand All @@ -82,6 +103,7 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings {
defaultWorkspaceDir: readString(record, "defaultWorkspaceDir"),
defaultModel: readString(record, "defaultModel"),
defaultServiceTier: readString(record, "defaultServiceTier"),
inboundAudioTranscription: resolveInboundAudioTranscription(record),
};
}

Expand Down
Loading