Skip to content

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.

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-227
const 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-466
if (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-147
if (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.

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.

If you reach for a custom type, you want one of:

  • A foundation type plus tags / metadata. Emit a pulse or sense and carry your domain semantics in tags or the metadata/payload records. Inbox filters and search work on those.
  • The native task API if you were modeling work (chorus_task_create / client.tasks.create), not the removed task signal.

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.