Custom signal types
There is no custom-signal-type feature in Chorus. Every emit transport — HTTP, SDK,
RPC, and both MCP surfaces — enforces the canonical type set. You cannot emit
signal_type: "deploy_request"; it is rejected before it is ever stored.
The foundation set — the eight foundation types — is not a
recommended vocabulary you may step outside of. On the emit path it is the only
vocabulary. An open string type does exist in the codebase, but it lives on the
persisted record schema, not on what /emit accepts. This page explains both: why
there are no custom signal types, and where the open string schema actually applies.
Why /emit rejects custom types
Section titled “Why /emit rejects custom types”The /emit body is validated by EmitRequestSchema, whose signal_type field is a
closed enum — the 8 foundation types plus a deliberate task passthrough — not an
open string:
// src/signals/validation.ts:12-17 — "task" is admitted at the schema layer ONLY so// emits reach the service's isLegacyTaskEmit guard and return the specific// LEGACY_TASK_EMIT_REMOVED error instead of a generic enum failure.const EmitSignalTypeSchema = z.enum([...CoreSignalTypes, "task"] as const);// src/signals/types.ts:16-27 — CoreSignalTypes is exactly the 8 foundation types.That schema is enforced by the route layer before the handler runs. Route-meta calls
safeParseAsync and returns a 400 envelope on any failure:
// src/http/route-meta.ts:216-227const parsed = await meta.validate.schema.safeParseAsync(raw);if (!parsed.success) { return c.json({ error: { code: meta.validate.errorCode, /* ... */ } }, 400);}The /emit route wires EmitRequestSchema as its validator with the error code
SIGNAL_EMIT_VALIDATION_FAILED (src/signals/routes.ts:1553). So a request with
signal_type: "deploy_request" gets 400 SIGNAL_EMIT_VALIDATION_FAILED and is never
stored. The handler never sees it.
The handler’s else branch is not an open door
Section titled “The handler’s else branch is not an open door”The emit handler branches on the type, and it is tempting to read the else branch as a
pass-through for custom types. It is not:
// src/signals/routes.ts:453-466if (FOUNDATION_SIGNAL_TYPES.includes(data.signal_type)) { signal = await executeCommand({ id: `signals.emit.${data.signal_type}`, ... });} else { signal = await signalService.emit(data, ...);}By the time control reaches this code, data.signal_type has already cleared the emit
enum (8 foundation types + the task passthrough). FOUNDATION_SIGNAL_TYPES is the
8 foundation types (src/commands/handlers/signals.ts:8-17). So the only value
that can ever take the else branch is task, and signalService.emit rejects it
immediately:
// src/signals/service.ts:144-147if (isLegacyTaskEmit(input)) { throw Errors.create(LEGACY_TASK_EMIT_REMOVED, ...); // 422}Nothing “goes straight in.” The branch is dead for any genuinely-custom type, and the one non-foundation type it can receive is rejected.
Every transport enforces the same set
Section titled “Every transport enforces the same set”There is no looser back door. Each emit surface lands on the same closed vocabulary:
| Surface | Custom types? | What it accepts | Source |
|---|---|---|---|
Raw HTTP POST /emit |
No | emit enum (8 + task passthrough) → 400 on anything else |
src/signals/routes.ts:1553, src/http/route-meta.ts:216-227 |
SDK client.signals.emit |
No | thin POST /emit — server enum applies |
packages/sdk/src/resources/signals.ts:79-80 |
MCP standalone (chorus-mcp) |
No | single tool, 8-type emit enum | packages/chorus-mcp/src/tools.ts:145-156 |
MCP embedded (src/mcp) |
No | 8 fixed per-type tools (no task) |
src/mcp/signal-tools.ts:67 |
The SDK looks the loosest because EmitParams.signal_type is typed as plain string
(packages/sdk/src/types.ts:349). That is a compile-time annotation only — emit is a
thin POST /emit (packages/sdk/src/resources/signals.ts:79-80) and does not relax
the server enum. A custom value type-checks in your editor and then fails at runtime with
the same 400 SIGNAL_EMIT_VALIDATION_FAILED.
The two MCP surfaces are not identical, but neither opens a custom path. The standalone
chorus-mcp emit tools use the 8-entry EMITTABLE_SIGNAL_TYPES
(packages/chorus-mcp/src/tools.ts:145-156; the 9-entry SIGNAL_TYPES survives only
on the webhook filter, where historical task rows remain matchable); the embedded
src/mcp loops over the 8 FOUNDATION_SIGNAL_TYPES and registers one fixed
signals_emit_<type> tool each (src/mcp/signal-tools.ts:67) — no task, and no
free-text signal_type argument on either surface.
Where the open string schema actually applies
Section titled “Where the open string schema actually applies”The open string type is real — but it is not an emit feature. It is on the persisted
record schema, SignalSchema:
// src/signals/types.ts:28 — used by SignalSchema.signal_type (types.ts:48)export const SignalTypeSchema = z.string().min(1);SignalSchema describes what can exist on a stored signal record — not what /emit
will create. The open string is there so the system can faithfully represent rows it did
not mint through the emit enum:
- Legacy rows written before the type set was locked down.
- Federation-received signals that arrived from another instance and are stored as-is.
So a stored record may carry a signal_type outside the foundation 8 (including
historical task rows), and the store,
indexing, and delivery paths will not reject it. But you cannot create such a record
through any emit transport. If you query inboxes with a type: filter, only records that
already carry that exact string will match — and on a single instance with no federation,
that set is exactly the canonical types.
What to do instead
Section titled “What to do instead”If you reach for a custom type, you want one of:
- A foundation type plus
tags/metadata. Emit apulseorsenseand carry your domain semantics intagsor themetadata/payloadrecords. Inbox filters and search work on those. - The native task API if you were modeling work (
chorus_task_create/client.tasks.create), not the removedtasksignal.
The canonical set is the contract on the wire. Treat the foundation types in
src/commands/handlers/signals.ts:8-17 — mirrored by
CoreSignalTypes in src/signals/types.ts:16-27 — as the vocabulary you have, not a
default you can extend.