Gajae-Code SDK
August 1, 2026 · View on GitHub
For embedding GJC in-process, see the embedding SDK guide. For a beginner-friendly application development guide (recipes, customization, and surface selection), see Building applications on the SDK.
A small, transport-agnostic SDK for receiving action-needed signals from a GJC session and sending replies back without scraping the terminal.
The stable contract is deliberately generic: every top-level running session hosts one loopback WebSocket endpoint by default, and integrations are user-written clients that connect to that endpoint. Telegram, Discord, Slack, mobile apps, and local tools all use the same JSON protocol. No upstream Rust, N-API, or wire-protocol change is required for a new integration.
Status: the Rust core (
crates/gjc-sdk) provides the wire protocol, action lifecycle, loopback WebSocket server, and endpoint discovery file. The bundled Telegram daemon is a reference client layered on top of this SDK; it is not the upstream topology.
TypeScript transport client
Install the standalone transport-only client when connecting to the v3 SDK WebSocket endpoint from TypeScript:
bun add @gajae-code/bridge-client
import { SdkClient } from "@gajae-code/bridge-client";
@gajae-code/coding-agent/sdk remains a compatibility re-export of this same SdkClient class and associated types, so both entry points preserve class identity. The package is a client for the documented v3 transport only: it does not restore the historical BridgeClient backend protocol, handshake/commands/SSE endpoints, or any direct host-control path.
Migration from the removed RPC mode
The retired --mode rpc, rpc-ui, and bridge modes are removed. The SDK v3
WebSocket endpoint is now the canonical external control/query bus.
| Retired RPC commands | SDK v3 control/query operations |
|---|---|
prompt, steer, follow_up, abort | turn.prompt, turn.steer, turn.follow_up, turn.abort |
| Model, thinking, queue, retry, and compaction controls | model.*, thinking.*, queue.*, retry.*, and compaction.* |
| Session and transcript queries | session.*, transcript.*, context.get, and session.stats |
| Workflow-gate response | workflow.gate_answer |
See the RPC-to-SDK v3 parity audit for the full matrix, partial equivalents, and evidence.
For a local non-WebSocket transport, run one of these commands:
gjc sdk serve --stdio
gjc sdk serve --socket <path>
It relays the identical SDK v3 frames over stdio or a Unix socket. Socket
clients send an authentication preface and the socket is mode 0600; stdio is
one parent-owned connection.
Python clients install the gjc_sdk package from python/gjc-sdk:
python -m pip install ./python/gjc-sdk
Import SdkClient with from gjc_sdk import SdkClient, then use
SdkClient.connect_ws, SdkClient.connect_socket, or SdkClient.connect_stdio.
The client supplies reply.token for replies.
Phase 2 still does not provide unattended negotiation, a cross-process reattach/registry, or a renderer-grade full event stream. No event-plane parity is claimed; see the audit's ranked Phase-2 register.
Architecture
GJC session (upstream) your client (anywhere)
┌───────────────────────────────┐ ┌──────────────────────────┐
│ ask-tool fires / agent idle │ action_needed │ Telegram / Discord / ... │
│ → notifications core │ ─────────────▶ │ render + collect reply │
│ ws://127.0.0.1:<port> (+token) │ ◀───────────── │ │
│ reply → resolve ask gate │ reply │ │
└───────────────────────────────┘ └──────────────────────────┘
- One endpoint per top-level session. Each top-level session runs its own loopback WebSocket server. Subagents do not host endpoints. Upstream does not maintain a shared daemon, singleton, or chat-to-session registry; multiplexing many sessions into one integration is a client-side concern.
- Hosted by default. SDK hosting is independent of notification
configuration. Set
GJC_SDK_DISABLE=1to opt out of hosting for a top-level session. - Notification delivery is optional. Configure and enable a managed notification adapter only when remote delivery is needed; the SDK endpoint remains available without one.
- Integrations are clients. A client discovers endpoint files, connects to
one or more WebSockets, renders
action_needed, and sendsreplymessages. - Zero upstream change. New transports do not require changes to
crates/gjc-sdkor the JSON protocol. - tmux-agnostic. The endpoint behaves identically with or without tmux.
Endpoint discovery
A running session writes a discovery file at:
<repo>/.gjc/state/sdk/<sessionId>.json
(.gjc/state/ is git-ignored.) Shape:
{
"version": 1,
"sessionId": "019edd41-...",
"pid": 12345,
"host": "127.0.0.1",
"port": 53124,
"url": "ws://127.0.0.1:53124",
"token": "<per-session token>",
"startedAt": 1718760000000,
"updatedAt": 1718760000000,
"stale": false
}
- The file is created
0700/0600(unix) and written atomically. - The token is in the file because clients need it; never log it raw. Stale files (dead PID, past TTL, or explicitly marked) are cleaned up on the next start.
Connect with the token as a query parameter:
ws://127.0.0.1:<port>/?token=<token>
A wrong/missing token is rejected at the handshake with HTTP 401.
Internal broker launch isolation
When the SDK starts its default internal broker or session host from the published TypeScript source, GJC uses a fixed Bun launch policy: --no-env-file, a product-owned empty bunfig.toml, absolute product entrypoint paths, and no inherited BUN_OPTIONS or mutable compiled-mode markers. The broker bootstraps from the product SDK directory rather than the caller project; a session host still runs with the lifecycle-authorized workspace as its process cwd.
This boundary prevents a child from newly loading caller-cwd or user-global Bun preload/dotenv policy. It cannot determine how a value already present in the parent environment was originally loaded, so ordinary provider/GJC environment values remain inherited. Default internal children, including compiled self-spawns, remove inherited BUN_OPTIONS so parent eval/test/inspect/debug/runtime options cannot be replayed into a detached child. Compiled binaries otherwise retain their existing self-spawn command contract, corroborated by a dedicated embedded marker and exact anchored Bun virtual-filesystem identity. The explicit GJC_SDK_SESSION_COMMAND session-host override remains a trusted legacy operator boundary and is not parsed as a shell-safe general command API. There is no broker-command override.
Broker and per-session discovery tokens remain in their authoritative private discovery files because clients need them. Launch errors, logs, and diagnostics redact those tokens and never include the child environment or isolation configuration contents.
Protocol
JSON text frames. Field names are camelCase; the type discriminator is
snake_case.
Server → client
action_needed — something needs attention:
{ "type": "action_needed", "id": "act_9e31", "kind": "ask",
"sessionId": "sess-1", "workflowGateId": "wg_run_stage_1",
"question": "Proceed?", "options": ["Yes", "No"], "recommendedIndex": 1 }
{ "type": "action_needed", "id": "act_a42f", "kind": "ask",
"sessionId": "sess-1", "question": "Choose a target", "options": ["A", "B"] }
{ "type": "action_needed", "id": "idle-sess-1-7", "kind": "idle",
"sessionId": "sess-1", "summary": "finished refactor; awaiting next step" }
idis an opaque, transient presentation/action ID. It is the only authority accepted by genericreply.id; use it only with the current authenticated endpoint. It is not a durable workflow ID.workflowGateId?: stringis optional, additive SDK v3 correlation metadata, present only for the active presentation of a durable workflow gate. When present, it equals that gate's Q12gate_id. Its public correlation key is(sessionId, workflowGateId)at the current authenticated endpoint; it never authorizes genericreply.kind: "ask"is answerable in interactive/TUI and SDK workflow-gate sessions.kind: "idle"is notify-only and ephemeral (not replayed to clients that connect later). Ordinary asks and idle frames omitworkflowGateId.recommendedIndex?: numberis optional, zero-based display metadata foroptions. Clients must validate that it is an in-range integer and ignore malformed values. Raw option labels and reply indices remain authoritative; never decorate submitted answers or infer a recommendation from position. The additive field is wire-compatible, but Rust consumers constructing the publicActionNeededstruct by literal must providerecommended_index: Nonewhen no recommendation exists.- This corrects the pre-v3 documentation invariant that
action_needed.id == gate_id: they are deliberately different values. Clients must not preserve that invariant, infer a relationship from question/options/order, or retain private route, claim, receipt, epoch, token, or endpoint-generation maps.
action_resolved — a pending action is now terminal and non-repliable:
{ "type": "action_resolved", "id": "act_9e31", "resolvedBy": "local" }
resolvedBy is local (a local/direct control retired the presentation), client (a remote generic reply won), or timeout.
reply_rejected — sent only to the client whose reply failed:
{ "type": "reply_rejected", "id": "act_9e31", "reason": "already_answered" }
Reasons: already_answered, unknown_action, invalid_answer,
resolver_unavailable, idempotency_conflict, unauthorized.
The frames above are the minimal contract every client implements. Threaded
clients (like the managed Telegram daemon) may also receive optional
server → client frames they can render or ignore: identity_header (one-time
per-session repo/branch/machine header), context_update (last message, task,
goal, token usage, model, diff), turn_stream (live/finalized turn output),
image_attachment (agent-produced images), activity (busy/idle, drives the
typing indicator), inbound_ack (delivery state of an injected user message),
session_closed (endpoint teardown; threaded clients may delete/archive the
remote conversation), config_update (current verbosity/redact), hello
(server capability/version), and pong. A minimal client only needs
action_needed, action_resolved, and reply_rejected.
Client → server
reply — answer a pending ask:
{ "type": "reply", "id": "act_9e31", "answer": 0, "token": "<token>" }
answer accepts:
- a number — zero-based option index (
0= first option); - a string — an option label, or free text;
- an object —
{ "selected": [0, "Maybe"], "custom": "..." }for multi-select.
Optional idempotencyKey makes retries safe: the same key + same body re-acks;
the same key + different body is rejected with idempotency_conflict.
Threaded clients may also send optional client → server frames: user_message
(inject/steer a turn with free text), config_command (toggle verbosity/redact
in-thread), hello (capability/version), and ping. A minimal client only
needs reply.
Model catalog query (Q10)
The SDK exposes the model catalog through the paged Q10 registry query. Q10,
models.list/current, models.list, and models.current are exact aliases:
each returns the same paged registry array, not a current-model singleton or a
filtered list. Continue using the returned cursor until page.complete is
true.
Each row preserves the five legacy fields (provider, id, name,
contextWindow, and maxTokens) and additively includes reasoning,
thinking, and current. currentThinkingLevel appears only on the current
row when the live session has a thinking level. The exported DTO types are
Q10Model, Q10ThinkingCapabilities, Q10ThinkingEffort,
Q10SettableThinkingLevel, Q10CurrentThinkingLevel, and
Q10ThinkingMode, all from @gajae-code/coding-agent/sdk; there is no public
/sdk/models subpath.
{
"provider": "runtime-provider",
"id": "reasoning-model",
"name": "Reasoning Model",
"contextWindow": 128000,
"maxTokens": 8192,
"reasoning": true,
"thinking": {
"validLevels": ["off", "minimal", "low", "medium", "high"],
"minLevel": "minimal",
"maxLevel": "high",
"mode": "effort",
"defaultLevel": "low"
},
"current": true,
"currentThinkingLevel": "high"
}
thinking.validLevels is always present and starts with "off"; it is the
canonical menu for model.set and never contains "inherit". For a
non-reasoning model it is exactly ["off"]. Successful reasoning rows always
include minLevel, maxLevel, and mode; only defaultLevel and raw levels
are optional. Raw levels deliberately keeps its descriptor order and
duplicates, while validLevels is the canonical, deduplicated menu clients
should render. "inherit" is a current-state readback value only and is rejected
as a model.set input.
Malformed reasoning descriptors are not client-recoverable catalog data. The
query returns the SDK's safe internal error rather than exposing a partially
formed row or descriptor details.
Prompt acceptance, termination, and reconciliation (Q26)
runtime.capabilities.promptTerminalOutcomeVersion is 1 when this contract is available. Its normalized TypeScript terminal outcome is:
type SdkPromptTerminalOutcome =
| {
kind: "stopped";
reason: "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
provenance: "agent" | "client_cancel";
}
| {
kind: "failed";
code: "prompt_failed" | "prompt_deadline_exceeded";
message: string;
provenance: "agent_failed" | "deadline";
};
turn.prompt returns { accepted: true, commandId, turnId, clientRef? } only after
its asynchronous preflight accepts the prompt. That receipt is a durable,
non-terminal pending claim, not a process-durable terminal result. The SDK
later finalizes that claim with exactly one SdkPromptTerminalOutcome; cleanup
may follow only after the claim is durable.
The authoritative public reconciliation query is Q26 /
turn.prompt_status, scoped to the same live session runtime. Its outcome
field is exposed only after finalization. A pending claim is never represented
or exposed as a terminal outcome.
Callers that must recover from a lost acknowledgement should assign one fresh
clientRef (a trimmed, non-empty string of at most 128 characters) to each logical
prompt. Reconnect to the same session endpoint and query with exactly one selector:
{ "type": "query_request", "query": "turn.prompt_status",
"input": { "clientRef": "request-018f" } }
or:
{ "type": "query_request", "query": "turn.prompt_status",
"input": { "commandId": "command-id", "turnId": "turn-id" } }
The result status is accepted, in_flight, terminal_ok, failed, or
unknown. Known records include acceptedAt; in-flight and terminal records add
startedAt and/or terminalAt; finalized records include outcome; failed records
also include a bounded sanitized error.code and error.message. Cursors, partial
generated-ID pairs, mixed selectors, and extra selector fields are rejected.
Correlated agent_end and agent_failed frames carry the same finalized
outcome. Clients must correlate those frames and Q26 by the prompt identifiers,
not infer terminality from stream activity or an earlier pending claim.
Reconciliation state survives client disconnect/reconnect. With the session-private
durable store (.sdk-reconciliation/), accepted and terminal prompt records also
survive GJC session-process restart for the same session identity within
capacity/TTL, subject to crash-consistent fsync. A non-terminal prompt record at
restart finalizes its pending outcome; if that claim is absent, it finalizes as
{ kind: "failed", code: "prompt_failed", ... }. This prompt-specific recovery
does not apply to skill records: active skill.invoke records retain
error.code = process_restart because their reconciliation is incomplete, not
proof of a skill failure. Eviction or absence still returns honest unknown; that
means the prior outcome is unknowable, not that execution did not occur. Active
records are capped at 128 per kind and are never aged into terminal. Terminal
records are retained for 15 minutes, capped at 256 per kind, and evicted
oldest-terminal first.
turn.prompt remains ordered and non-idempotent. Its envelope idempotencyKey
does not replay a response or produce idempotency_conflict. A retained duplicate
clientRef fails before execution with client_ref_conflict, but callers must not
reuse a clientRef as a retry mechanism: after eviction the same value can identify
a new prompt while the old outcome remains unknown.
turn.abort returns a typed disposition. A caller that does not own the target
receives resource_gone; it must not treat that result as cancellation of another
prompt.
sdk.promptDeadlineMs defaults to 1_800_000. It accepts only safe integers in
[60_000, 86_400_000]; there is no disable value. The SDK snapshots the setting
when the prompt is durably accepted. Terminalization then has a fixed 10_000 ms
grace period, which is not configurable. A controlled terminal failure reaches ACP
as JSON-RPC -32603 with data.code of prompt_failed or
prompt_deadline_exceeded.
Skill invoke reconciliation (Q28)
skill.invoke accepts optional clientRef and returns an early accepted receipt
{ accepted: true, commandId, turnId, clientRef?, name, path, lineCount?, args? } after
durable/preflight accept (SDK control path), not after skill completion. Query prior
status with Q28 / skill.invoke_status using the same selectors as Q26. Kind-scoped
indexes mean prompt and skill clientRef values never collide. Skill records use the
same capacity/TTL limits, but an active skill record at restart settles with
error.code = process_restart.
Model profile discovery and validation (Q27)
Q27 / models.profiles.list pages the effective model-profile catalog owned by
the attached session. Rows are sorted by exact ID and contain only:
{ "id": "codex-medium", "displayName": "codex-medium", "source": "builtin" }
source is builtin or configured. Profiles from <agentDir>/models.yml
override built-ins with the same exact ID, including their display label. Profile
IDs are not trimmed, case-folded, sanitized, or restricted to safe-token names;
discover the exact ID and send it unchanged. The retired codex-standard alias is
fallback-only and never shadows a configured profile with that exact ID.
Q27 uses retained-revision, connection-bound pagination. Continue an issued cursor
to finish its stable snapshot; a fresh cursorless query observes the current
registry. The query accepts no root, path, or selector input. An invalid or
unreadable models.yml fails closed with model_profile_registry_error rather
than returning a plausible built-ins-only catalog.
Broker session.create, session.fork, and session.resume validate modelPreset
before spawning against the same <broker.settings.agentDir>/models.yml authority
that the child receives through GJC_AGENT_DIR / GJC_CODING_AGENT_DIR. Unknown
IDs return unknown_model_profile. Both typed errors include bounded details
with requestedProfile where applicable, whole exact availableProfiles entries
that fit the detail budget, and discoveryQuery: "models.profiles.list". The
discovery pointer is authoritative when the bounded error cannot include every ID.
Active provider query (Q29)
Q29 / providers.list/active pages the providers currently eligible for model
selection through the same authenticated, retained-snapshot envelope as Q10. Each
row is the non-secret DTO { provider, connectionKind }, where connectionKind
is credential or credentialless.
Provider IDs are returned exactly as they appear in Q10 model.provider: existing
mixed-case, spaced, punctuated, and long custom IDs are preserved without aliases
or normalization. Rows are deduplicated and ordered by UTF-8 provider bytes.
Join Q29 to Q10 by exact provider ID; Q10 remains the full configured catalog.
A credentialed discovery-only provider appears only after fresh discovery proves the exact model is usable. Static configured models can appear without a network probe. The query never invokes a model, refreshes credentials, probes a remote account, or exposes credentials, account metadata, paths, or provider responses.
Resolver failures are atomic and return
{ "code": "internal", "message": "Unable to resolve active providers." }.
They omit a page and restart metadata. An expired continuation follows the shared
cursor contract and returns error.code: "cursor_expired" with
error.restartQuery: true. Malformed cursor strings return invalid_cursor;
cross-query or selector mismatches return invalid_input.
Answer semantics
A remote reply answers a pending ask in every session state:
- Interactive / TUI mode: the ask tool races the local selector against the
remote reply (first valid answer wins). A client submits generic
replyusing the active presentationid; a local answer emitsaction_resolved(resolvedBy: "local") and that presentation becomes non-repliable. - SDK workflow gate: generic
replystill uses the active presentationid, neverworkflowGateId. The resolved gate drives the session the same way a local answer would.
A session has at most one active answerable presentation. Interactive asks and durable workflow gates are serialized; further Q12 gates wait in a durable queue. A same-server reconnect replays the active action_needed with the same presentation ID. After a process restart, previously pending or accepted-but-unadvanced records are quarantined diagnostics and a reconstructed workflow remints fresh durable gate and presentation IDs. Terminal, stale, and reissued action IDs never regain authority.
Generic and direct controls may race. Once the native generic claim is acquired, it wins; a direct control that atomically retires the exact unclaimed active presentation first wins instead. Losing direct controls fail without advancing the gate, and losing generic replies are stale/non-repliable. Clients must not retry by matching text, durable IDs, or presentation history; they must fail closed rather than guess when session or action identity is unsafe or ambiguous.
Durable workflow controls and Q12
workflow.gate_answer and workflow.plan_approve operate on the durable
Q12 gate_id, not action_needed.id. Both accept optional
expectedSessionId; clients should always send the sessionId observed from
the current authenticated endpoint:
{ "type": "control_request", "operation": "workflow.gate_answer",
"input": { "id": "wg_run_stage_1", "response": "approve", "expectedSessionId": "sess-1" } }
{ "type": "control_request", "operation": "workflow.plan_approve",
"input": { "id": "wg_run_stage_1", "choice": "approve", "expectedSessionId": "sess-1" } }
expectedSessionId omission remains accepted and audited for the entire SDK v3 line so deployed v3 control clients continue to work; new clients must send it now. It cannot become mandatory, or be removed from the controls, before SDK v4 and at least one full published deprecation release/window with deployed-client notice. A supplied session mismatch is rejected before the gate resolver runs. Neither control accepts a presentation ID, remaps an old ID to a reminted gate, or uses heuristic matching.
Q12 (workflow.gates.list) exposes durable query records and additive SDK v3 diagnostics. A pending record preserves its workflow fields including gate_id and adds id: "pending:<gate_id>" and tag: "pending". A restart quarantine diagnostic uses id: "diagnostic:<gate_id>", tag: "quarantined", and optional lifecycle containing state: "quarantined", its restart reason, quarantinedAt, and an optional supersededByGateId after a remint. Diagnostics are query-only: they cannot be routed, answered, or promoted. Treat Q12 as the durable status surface, not as generic-reply authority.
Coordinator MCP question pull loop
The Coordinator MCP bridge is a separate, public-safe pull surface for external coordinators. gjc_coordinator_list_questions requires session_id and reconciles pending workflow.gates.list rows on every call, returning bounded public questions, diagnostics, and reconciliation. It accepts status: "pending"; status: "open" remains a compatibility alias. Multiple pending rows can be returned. A pending row carries its safe question shape, public option ids, and answer_binding, never raw/private gate payloads or values.
gjc_coordinator_submit_question_answer requires session_id, turn_id, question_id, answer_binding, answer, idempotency_key, and allow_mutation: true. It re-lists/revalidates after restart and resolves through workflow.gate_answer, not generic ask.answer. An incomplete reconciliation returns terminal_uncertain; stale, terminal, missing, or ownership-mismatched rows cannot be answered. Re-list after restart rather than retaining old identifiers. An identical retry with the same idempotency key replays the accepted result; conflicting reuse returns idempotency_conflict.
This contract does not change #2549/#2551 or unattended plain-CLI behavior.
Rust and N-API compatibility
The Rust ActionNeeded, ServerMessage, and register_ask APIs remain
legacy-compatible and uncorrelated. Correlation is available through additive
Rust workflow-frame decoding/current-reader APIs and the workflow registration
path; consumers that need correlation must opt in explicitly. N-API likewise
retains registerAsk, and adds registerWorkflowGateAsk for a correlated wire
frame plus registerArbitratedAsk and retireIfUnclaimed for in-process
presentation arbitration. The arbitration lease and all claim/receipt/epoch
state remain private: these APIs do not create a public authority value.
Runtime and native addon release pairing
The @gajae-code/coding-agent runtime and @gajae-code/natives native addon ship from the same source release at exact matching package versions. The native loader requires the matching version sentinel; mixed native/runtime versions are unsupported and must not claim SDK compatibility.
Minimal client example
import { readFileSync } from "node:fs";
import WebSocket from "ws";
const { url, token } = JSON.parse(
readFileSync(`.gjc/state/sdk/${sessionId}.json`, "utf8"),
);
const ws = new WebSocket(`${url}/?token=${encodeURIComponent(token)}`);
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === "action_needed" && msg.kind === "ask") {
// present msg.question / msg.options to the human, then:
ws.send(JSON.stringify({ type: "reply", id: msg.id, answer: 0, token }));
} else if (msg.type === "action_resolved") {
// mark this action as no longer answerable in your UI
} else if (msg.type === "reply_rejected") {
// e.g. reason === "already_answered" → the ask was answered elsewhere
}
});
Swap ws for a Telegram bot's long-poll loop, a Discord gateway client, or a
Slack socket-mode app — the contract above is all you implement.
Fallback chains
Model-role selectors may be ordered fallback chains; see Fallback chains for configuration and retry-budget details. Resolution-time skips do not consume attempts. When a request-time retry advances to another eligible entry, the selected default fallback remains sticky for later prompts in that session until an explicit model selection or a chain reset changes it.
model_fallback_switched { eventId, from, to, reason, role, scope, activeIndex, chainLength, attemptsUsed } is the canonical session lifecycle event for every real fallback-model switch. It replaces the legacy retry_fallback_applied / retry_fallback_succeeded event names. Embedding clients can subscribe to this session event; generic WebSocket clients should use only the protocol frames documented above and any adapter-specific status updates they support.
Managed session-directory adapter guidance
SDK adapters that need to inspect saved sessions must import only the supported public surface from @gajae-code/coding-agent/sdk:
import {
SESSION_DIRECTORY_API_VERSION,
listManagedSessionCandidates,
resolveManagedSessionScope,
} from "@gajae-code/coding-agent/sdk";
if (SESSION_DIRECTORY_API_VERSION !== 1) throw new Error("Unsupported session-directory API");
const resolved = await resolveManagedSessionScope({ cwd: process.cwd() });
if (resolved.kind === "resolved") {
const listing = await listManagedSessionCandidates({ scope: resolved.scope });
// Consume only listing.kind === "complete" and its owned candidates.
}
This is a readonly resolver/listing contract. Do not import @gajae-code/coding-agent/session/internal/*, derive v2-… names, write bindings, or implement migration/cleanup in an adapter; private internal subpaths are intentionally unavailable from the packaged module. Treat network_unsupported, binding/security errors, incomplete listings, invalid candidates, and foreign candidates as non-authoritative results rather than retrying with a guessed path.
The resolver uses canonical native identity: supported POSIX and Windows local aliases can designate one scope, while UNC/network workspaces are unsupported. Scope digests are collision-resistant identifiers, not injective aliases, credentials, or authentication. The owner-only checks protect managed local storage paths but do not authenticate an adapter or make hostile concurrent filesystem races safe. Adapters that need mutations must use the higher-level lifecycle/session APIs rather than the readonly directory API.
Managed notification adapters
GJC ships managed SDK-client adapters for Telegram, Discord, and Slack. They use one local SDK endpoint per session; the adapters do not change the wire protocol, keep endpoint credentials in provider state, or expose a remote shell.
The recommended interactive path is /settings → Notifications. It owns
setup, health, test, recovery, reconnect, local enablement, and Telegram
removal without exposing stored credentials.
gjc notify setup remains the authoritative CLI fallback for headless and
automated environments.
Notification credentials and notifications.* settings are global-only.
Project notification keys are
ignored and runtime notification overrides are rejected. Telegram pairing
revalidates the complete bot-token/chat identity immediately before polling and
again before activation. A foreign or unknown owner is never killed, reloaded, or taken over;
setup fails closed without saving or exposing the raw token.
Configuration completeness, provider-local quarantine, durable desired intent, effective enablement, runtime readiness, and delivery outcomes are separate contracts. The global notifications.enabled master never erases provider credentials or desired flags. /settings edits secrets through explicit keep, replace, or remove actions, commits only the selected provider in one CAS batch, and reports post-commit observer or activation failures without pretending the durable save rolled back. Malformed provider-local values are quarantined for explicit repair while safe sibling providers remain usable; malformed global notification structure remains fail-closed.
GJC_NOTIFICATIONS=0 suppresses only automatic generic current-session admission. Explicit /notify on can opt the current session back in without mutating durable provider state, and direct provider APIs remain governed by provider effectiveness and their own runtime readiness. If Telegram ownership is proven foreign while Discord or Slack is effective, GJC publishes the chat daemon endpoint under the isolated .gjc/state/chat/sdk/ discovery path; the blocked Telegram scanner never receives the shared endpoint token.
- Telegram notification onboarding documents
gjc notify setupand private-chat pairing. - Discord notification onboarding documents
gjc notify setup discord, required configuration, thread lifecycle, and least-privilege permissions. - Slack notification onboarding documents
gjc notify setup slack, Socket Mode configuration, immediate envelope ack, and thread lifecycle.
gjc notify status reports provider completeness, repair/quarantine state, desired intent, effective enablement, and masked tokens. Destination identifiers remain visible and may be sensitive. The Discord and Slack setup commands are non-interactive and require their documented identifier and token flags; supply secrets through an approved local mechanism, not examples, committed files, shell history, logs, or chat. gjc notify health --provider <provider> --probe performs a provider-owned REST diagnostic even when complete credentials are intentionally inactive, while gjc notify test --provider <provider> additionally requires effective enablement and runtime readiness.
The daemon/session engine is shared. Session discovery, WebSocket protocol,
redaction decisions, rate-limit pooling, reply routing, singleton ownership, and
lifecycle control are not reimplemented by each chat surface. Telegram, Discord,
and Slack adapters are thin presentation layers: they render internal notification
events into transport payloads and map transport interactions back to {sessionId, actionId,answer} replies.
Discord maps a session to an archiveable thread; resume unarchives it or creates a replacement, and stale/superseded thread input fails closed. Slack maps a session to an immutable root thread; resume creates a new root, acknowledges all Socket Mode envelopes immediately, and does not persist a Socket Mode cursor.
The Discord and Slack acceptance suites use fake providers only. They exercise provider failure, reconciliation, restart, dedupe, lifecycle, and reconnect paths without live credentials or live-provider end-to-end tests.
Managed Telegram daemon (bundled reference client)
GJC also ships a managed Telegram reference client for the common phone-notify
workflow. It remains a client of the generic SDK: it scans session discovery
files, opens each session WebSocket, and routes Telegram replies back to the
matching endpoint. Run gjc notify setup once to complete Telegram's interactive
private-chat pairing flow.
For Telegram forum topics, the daemon deletes the per-session topic when the local notification endpoint shuts down, so it disappears from the topic list. A resumed session creates a fresh topic before sending again. The bot must be allowed to delete messages in that chat; without that permission, deletion is best-effort and delivery continues.
Singleton poller and trust model
Telegram getUpdates allows only one active long-poll owner per bot token. The
managed daemon enforces one bot token = one getUpdates poller with a local
lock/state file under the agent directory. New sessions attach to the existing
fresh daemon owner instead of starting another poller, preventing Telegram 409
conflicts.
The trust model is intentionally strict:
- setup pairs exactly one private Telegram chat;
- runtime accepts updates only from that paired chat id;
- groups, supergroups, channels, and unpaired users never receive session names, action ids, pending status, or configuration hints;
- daemon state stores a token fingerprint, not the raw bot token.
Routing in private-chat topics
The paired private chat prefers per-session Telegram topics (Threaded Mode). The daemon tags messages by session, stores compact callback aliases for inline buttons, and routes replies back to the exact session/action. A forum-enabled supergroup is no longer required: when the bot owner enables Threaded Mode in @BotFather, the daemon creates one topic per session in the paired private chat. GJC cannot enable Threaded Mode through the Bot API; setup only verifies the capability and guides the manual BotFather toggle.
If BotFather's per-bot Bot Settings menu does not show Threads Settings
or Threaded Mode, the supported fallback is the normal private-chat pairing.
Setup can be saved as threaded=unverified/threaded=unknown, and the daemon
still tries topics when Telegram allows them. When createForumTopic is refused,
the daemon does not drop the send: it routes the notification to the normal
(flat) paired private chat and posts a one-time nudge: Flat Telegram private chat supports outbound notifications and inline ask buttons only. Enable Threaded Mode in @BotFather > Bot Settings > Threads Settings for free-text replies and session commands. Pairing is private-only, so flat delivery stays within the user's own
private DM.
Supported reply paths:
- tap an inline button on an ask notification;
- reply inside the session's thread/topic (replies are thread-native; the topic identifies the session, so no session tag is needed).
In threaded mode the user can also adjust per-session behaviour with in-thread
config commands: /verbose (per-tool-turn assistant text), /lean (settled
assistant answer at idle plus immediate ask lead-ins; the default),
/verbosity <lean|verbose>, and /redact <on|off>. The legacy
/answer <session-tag> <answer> command is removed — replies are routed by the
topic they arrive in.
Flat fallback keeps outbound notifications and inline-button answers working, but
plain free-text never guesses from the global pending-ask set. Free-text replies
and /verbose//lean//verbosity//redact commands are thread-native and
require Threaded Mode/topic routing. Enable Threaded Mode in @BotFather > Bot
Settings > Threads Settings when you need free-text replies or session commands.
Do not pair a group, supergroup, or channel to work around a missing BotFather
menu; the bundled setup flow is
private-chat only, and non-private chat ids remain fail-closed to avoid session
data leaks.
Unknown, expired, or restart-unvalidated callback aliases fail closed: the daemon sends guidance and does not guess a target session or action.
Discord and Slack setup
Discord and Slack use the same internal notification events and reply protocol as Telegram. Store only runtime credentials in local GJC settings or environment; never paste bot tokens, webhook URLs, transcripts, prompts, host paths, or raw logs into docs, tests, issues, or PR comments.
Configuration keys:
notifications:
enabled: true
discord:
botToken: "<local Discord bot token>"
applicationId: "<Discord application id>"
guildId: "<Discord guild id>"
parentChannelId: "<Discord parent channel id>"
slack:
botToken: "<local Slack bot token>"
appToken: "<local Slack app-level token>"
workspaceId: "<Slack workspace id>"
channelId: "<Slack channel id>"
authorizedUserId: "<Slack user id authorized for inbound replies and commands>"
redact: true
The bundled adapters intentionally render public-safe message bodies and return route metadata only for pending internal actions. They do not own polling, session scans, daemon locks, rate limits, or SDK lifecycle. Production transport senders should consume the adapter payloads and keep all credential-bearing HTTP or gateway details outside logged payloads.
Redaction
notifications.redact strips sensitive content before remote delivery, but
asks are exempt: an ask is an interactive prompt the human must read and
answer remotely, so its question and options are always sent unredacted
(otherwise it would be unanswerable). When redaction is enabled, idle
summaries are removed and streamed content frames (turn_stream,
context_update, image_attachment) are suppressed at their emit sites. When
redaction is disabled, all content is delivered unchanged.
Local /notify
Inside a GJC session, /notify controls the current session only:
/notify statusreports enabled/disabled state, daemon observation when known, and redaction state without printing secrets;/notify offdisables the current session's notification endpoint and removes its discovery record without mutating global Settings;/notify onre-enables the current session when global setup is complete andGJC_NOTIFICATIONS=0is not forcing opt-out.
Manual Telegram CLI is for debugging
packages/coding-agent/src/sdk/bus/telegram-cli.ts remains as a manual
reference/debug client and template for other integrations. It is not the primary
Telegram UX.
bun run packages/coding-agent/src/sdk/bus/telegram-cli.ts --bot-token "$BOT_TOKEN"
By default it refuses to start when a fresh managed daemon already owns the same
bot token for the same paired chat, because a second poller will cause Telegram
409 conflicts. Use --force only for deliberate debugging when you have stopped
or intentionally want to override the daemon guard.
Two client surfaces: per-session vs daemon-owned lifecycle control
The SDK now exposes two distinct surfaces. Do not confuse them:
-
Per-session notification clients (the normal, documented contract above). A client discovers
<repo>/.gjc/state/sdk/<sessionId>.json, connects to that session's loopback WebSocket, and handlesaction_needed,action_resolved,reply_rejected, and the optional threaded frames. This is all an ordinary integration (Telegram, Discord, Slack, mobile, local tools) needs. It requires zero upstream changes. -
The daemon-owned session lifecycle control endpoint (privileged). A separate, session-independent, loopback-only, authenticated control endpoint that accepts
session_create/session_close/session_resumeframes. It exists because creating a session cannot use a per-session socket (none exists before the session does). It is not part of the normal integration contract: ordinary clients never implement it. Only the bundled, trusted daemon (e.g. the managed Telegram daemon) speaks it.
Lifecycle control endpoint
- Discovery:
<agentDir>/notifications/control.json(daemon-owned, mode0600), distinct from per-session endpoint files. It carries only non-secret endpoint metadata (url/host/port/pid/owner). The control token is held in memory by the daemon (the sole client) and is never written to disk. - Auth and routing: the loopback SDK broker requires
?token=<control-token>(HTTP401otherwise) and re-checks every lifecycle frame'stoken(unauthorizedon mismatch). It routes accepted requests through the canonical SDK lifecycle operation. - Frames:
session_create(targetexisting_path|worktree|plain_dir),session_close(hard-kill, history preserved, recoverable),session_resume(reattach if alive, else cold-restart from history); responsessession_create_response/session_close_response/session_resume_response/session_lifecycle_error. The protocol also defines a replayablesession_readyper-session frame for readiness-gated creates; the current MVP daemon replies once the tmux launch is requested (see the phone guide) rather than waiting on it. Inline prompt text (-- <prompt>) is rejected in the MVP.
Trust model and hardening (daemon side)
The control endpoint trusts the configured paired chat for any path (an accepted risk). It is hardened around that boundary:
- Strict paired-chat gating — non-paired chats are rejected before any path parsing, filesystem, or process action.
- Durable idempotency — a locked, atomic, fsynced ledger keyed by
chatId:updateId+ request hash (telegram-lifecycle-idempotency.json). Duplicate updates never repeat side effects, including across daemon restart; a duplicate while in-progress reports pending (never a second spawn); a same id with a different body isduplicate_conflict; an effect failure is recordedterminal_uncertain(never auto-respawned). - Per-chat create rate limit.
- Audit log — append-only
telegram-lifecycle-audit.jsonl(0600) recording every accept/reject/duplicate/rate-limit/spawn/success/failure. Raw control tokens and raw prompts are never logged (prompt hash + byte length only). - Inline prompts rejected (MVP) —
session_createwith-- <prompt>text is rejected with usage; no prompt is ever placed in argv, audit, or responses. (A redacted prompt-ref flow is reserved for a future revision.) - GJC-managed-only close — force-close re-reads the exact
@gjc-profileimmediately before kill and requires the@gjc-session-id(and optional@gjc-session-state-file) tag to match; it never touches non-GJC tmux. - Recent-activity picker — sessions are ranked by history-file mtime and enriched with terminal breadcrumbs so the operator picks a recent repo/session instead of typing raw paths. Ambiguous resumes fail closed with candidates.
Phone test guide (create / close / resume from Telegram)
End-to-end manual check once gjc notify setup has paired your private chat:
- Pair + start. Run
gjc notify setup(BotFather token, DM the bot to pair). Start any GJC session with notifications enabled so the daemon owner is running (gjc launchin a repo, orGJC_NOTIFICATIONS=1). The owner starts the loopback control endpoint and accepts/session_*while running; with zero active sessions it still idle-exits after the inactivity timeout. - Create. From your paired chat, pick
/session_createfrom the Telegram command menu or send/session_create path <repo-dir>(or/session_create worktree <repo> <branch>, or/session_create dir <newdir>).<repo-dir>,<repo>, and<newdir>may use~/~/...for your own home directory; named-user forms such as~alice/repoare rejected. The bot replies once the tmux launch is requested; the session shows up in/session_recentonce it is ready. (Inline prompts via-- <text>are rejected for now with usage text.) - List.
/session_recentshows recent sessions (most-recent first) to copy an id from. - Close.
/session_close <sessionId>hard-kills the GJC-managed session (history is preserved); the bot confirms. - Resume.
/session_resume <sessionId|prefix>reattaches if it is still alive, otherwise cold-restarts it from saved history. An ambiguous prefix replies with the matching candidates instead of guessing.
Commands are accepted only from the paired chat; create is rate-limited,
and all lifecycle commands are idempotent per Telegram update id and audited (no
tokens or prompts are logged).
For an automated proof of the wire path without a real bot, see
packages/coding-agent/scripts/g011-daemon-path-smoke.ts (real native control
endpoint + loopback WebSocket).