Plugin Host Capability Gaps: audio / video parts
This page records host-side capability gaps that plugins cannot close on their own. A couple of them have a limited workaround (audio can borrow the frontend player, see below), but a workaround is not the native channel — and video has none at all. Every claim is verified against the sources it cites:
app/main_server/character_runtime.pyandplugin/server/messaging/proactive_bridge.pyfor host behaviour,main_logic/core/proactive.pyfor how arespondpush's images are delivered,plugin/core/context.py+plugin/settings.py+plugin/message_plane/ingest_server.pyfor the wire payload and its ceiling,plugin/server/routes/media.py+plugin/sdk/shared/core/images.pyfor the temporary media store,plugin/sdk/shared/core/push_message_schema.pyfor the part schema, andstatic/jukebox/music_ui.jsfor the frontend player. Only function and constant names are cited, so line drift cannot make the page wrong.Status (re-verified 2026-08-28): the "Issue 1: image parts have no user-visible channel" gap this page originally reported was closed by #2835 (
1d654e302, merged 2026-08-28); it is rewritten below as the "Already shipped" section. What is still missing is a host-side consumer for audio / video parts.Plugin-side solvable adaptation issues (double-reply, session interruption, message listening, background photo gating, missing
target_lanlan, etc.) were already fixed inside the community plugins themselves and are out of scope here.
Already shipped: the user-visible channel for image parts (#2835)
For a push whose visibility contains "chat", the text and image parts render into the user's chat window in their original order, as a source-labelled role="system" bubble (a system chip) — neither the assistant's bubble nor the user's. A plugin may still phrase its text in the character's voice; the label says where it came from.
The branch is gated on visibility alone, independently of ai_behavior: respond, read, and blind all render. ai_behavior only decides whether the same parts also enter the model's context:
# The user sees the image; the model has no idea it exists.
push_message(
visibility=["chat"],
ai_behavior="blind",
parts=[{"type": "image", "data": <png bytes>, "mime": "image/png"}],
)
# The user sees it, the model sees it, and she replies to it right away.
push_message(
visibility=["chat"],
ai_behavior="respond",
parts=[{"type": "image", "data": <png bytes>, "mime": "image/png"}],
)One caveat on read: reaching the model is best-effort there. The host clears the pending images when the current session has no stream_image (or there is no session at all), and again when the realtime provider has no native vision — read owns no ticket-bound channel to deliver a VISION_MODEL description, so the host bails out early and says so in the log. The chat bubble still renders in both cases. When the image must reach the model, use ai_behavior="respond", whose images ride the callback and need no session yet.
Both image sources work:
- inline:
parts=[{"type": "image", "data": <bytes>, "mime": "image/png"}], encoded asbinary_base64on the wire. Keep these small — see the wire budget below; - local temporary URL:
await ctx.images.upload(data)returns an image part you can drop straight intoparts; its URL looks likehttp://127.0.0.1:<port>/media/<id>and is served by the host's temporary media store (plugin/server/routes/media.py). This is the right choice for anything that is not tiny: the URL costs a handful of bytes on the wire.
Implementation path: _ordered_plugin_chat_blocks / _build_plugin_chat_blocks build the blocks → LLMSessionManager.render_chat_blocks emits a chat_blocks WS frame → the frontend's appendReactChatBlocks renders the system chip.
On the model side both read and respond first resolve base64 through _resolve_plugin_model_image / _fetch_plugin_image_base64, but they deliver it differently — worth knowing if you go looking for the image in a log: read hands it straight to the session's stream_image, while respond defers it onto the callback (media_images) so it arrives together with the proactive response — in voice mode via _stream_cb_media → stream_image, in text mode as the images= argument of prompt_ephemeral.
Limits that still apply
- Arbitrary external URLs are still rejected.
_is_local_plugin_media_urlrequireshttp+ a loopback host + a/media/<id>path with no query, fragment, or credentials. When it fails, the chat render skips that part and the model path logsplugin image resolve failed; dropped. To send an external image, run it throughctx.images.upload()first. - The transport ceiling bites before the host quotas do. The whole
MESSAGE_PUSHenvelope must pack underMESSAGE_PLANE_PAYLOAD_MAX_BYTES(default 256 KiB, overridable viaNEKO_MESSAGE_PLANE_PAYLOAD_MAX_BYTES), and_build_wire_payloadstill carries a legacybinary_datacopy alongsideparts[].binary_base64— so one inline image travels roughly twice, and a source image much over ~110 KiB is rejected atplugin/message_plane/ingest_server.pybefore any host-side quota is consulted. Anything larger must go throughctx.images.upload(). - The chat path and the model path have separate quotas. Chat: at most
_PLUGIN_CHAT_IMAGE_MAX_COUNT = 8images and_PLUGIN_CHAT_INLINE_TOTAL_MAX_BYTES = 8 MiBof inline bytes. Model: a per-image_PLUGIN_IMAGE_MAX_BYTES = 8 MiB, and each push shares the per-turn callback budget_PLUGIN_IMAGE_MAX_COUNT/_PLUGIN_IMAGE_TOTAL_MAX_BYTES. Images over budget are dropped without affecting the rest of the push. These 8 MiB figures bound what the host accepts once a push is through the transport — for inline parts the wire ceiling above is the limit you actually hit. ctx.images.upload()cannot be called from a lifecycle handler — the plugin command loop is not servicing upload responses while those run, so it raisesRuntimeError. Call it from an entry, timer, message, or custom event handler instead.- Uploads are normalised to JPEG: longest edge
MAX_IMAGE_EDGE = 2048, source limitsMAX_SOURCE_IMAGE_BYTES = 32 MiB/MAX_SOURCE_IMAGE_PIXELS = 16M pixels, output limitMAX_UPLOADED_IMAGE_BYTES = 8 MiB.
Still missing: audio / video parts are dropped by the host
Symptom: pushing an audio / video part produces no output. On the model side, only ai_behavior in ("respond", "read") enters the media loop, which warns once and drops any non-image part; with ai_behavior="blind" the loop is not entered at all, so there is no diagnostic either. The chat render does not know these part types either (_build_plugin_chat_blocks handles only text and image), so visibility=["chat"] renders nothing and logs nothing.
# respond / read: the host enters the media loop, warns, and drops audio/video
push_message(
visibility=["chat"],
ai_behavior="respond",
parts=[{"type": "audio", "data": <wav bytes>, "mime": "audio/wav"}],
)Root cause (source-verified):
- In
app/main_server/character_runtime.pythe media loop as a whole is guarded byai_behavior_v2 in ("respond", "read"); inside it, thepart_type != "image"branch logslogger.warning("[EventBus] media_part type=%s not yet supported (mime=%s); dropped")andcontinues. The comment explains thatstream_audiois the realtime microphone PCM pipeline (fixed sample rate + RNNoise gating), not a generic file injector, and that there is no video API. - So
{"type": "audio"}/{"type": "video"}are defined in the v2 schema (plugin/sdk/shared/core/push_message_schema.pyand the official guide) but the host consumption side is not implemented.
Impact: plugins cannot push voice or video content into the session (TTS audio files, music clips, game cutscenes). Verified workarounds:
- audio:
ui_action=media_play_urlplays a directly playable, non-HLS audio URL through the frontend audio player (the bridge maps the action to amusic_play_urlevent, which the frontend routes todispatchMusicPlay). Two preconditions:- the URL must already be on the playback allowlist.
sendMusicMessageDetailedgives it a 500 ms grace window waiting for amusic-allowlist-updatedevent; if the URL is still not allowlisted it rejects withunsafe_urland shows a toast. Register it first withui_action=media_allowlist_add(domains, or exacthttp_urls), then push the playback action; - the URL must not be an HLS stream.
isUnsupportedMusicStreamruns before the player starts and rejects.m3u8withunsupported_streamplus an error toast, allowlisted or not.
- the URL must already be on the playback allowlist.
- video: no workaround.
media_play_urlcannot stand in for it: themusic_play_urlevent the bridge emits carries nomedia_type, the frontend always hands it to the music/audio player, and there is no video event channel on the frontend at all.
Expectation: provide a host-side consumer for audio / video parts (audio could be injected into the session or forwarded to the frontend player; video should at least support the url form) — or mark both part types as unimplemented at the schema layer so neko-plugin check can stop a plugin from shipping them.
Verification
- Push
push_message(visibility=["chat"], ai_behavior="blind", parts=[{"type": "image", "data": <png bytes>, "mime": "image/png"}]): a system-chip image bubble labelled with the plugin's name appears in the chat window, and the image is absent from the model's context. - Same payload with
ai_behavior="respond": the same image bubble appears AND the image reaches the model (carried on the callback —prompt_ephemeralin text mode,stream_imagein voice mode), so she replies about it. - Replace the part with
{"type": "image", "url": "https://example.com/cat.png"}and keepai_behavior="respond"(the model path is what logs here;blindnever enters the media loop): the external URL is rejected — nothing renders and the host logsplugin image resolve failed; dropped. Using the part returned byawait ctx.images.upload(<bytes>)works on both sides. - Push
parts=[{"type": "audio", "data": <wav bytes>, "mime": "audio/wav"}]withai_behavior="respond": the host logsmedia_part type=audio not yet supportedand produces nothing; withai_behavior="blind"there is no log at all (the media loop is skipped and the chat render does not handle audio parts).
