# External Handlers

`pi-telegram` owns a single `getUpdates` long-poll connection per bot. Other pi extensions cannot open a competing poller against the same bot — the Telegram Bot API uses a per-bot `offset` cursor, and two pollers race each other and lose updates.

This document describes the registry that lets layered pi extensions running in the same pi process hook into `pi-telegram`'s polling loop and react to inbound Telegram updates **before** `pi-telegram`'s default routing fires.

It is the runtime counterpart to [Callback Namespaces](./callback-namespaces.md): callback namespaces define how to share `callback_data` cleanly; external handlers define how to observe and optionally short-circuit the dispatch of those updates.

## When to use it

Use it when a layered extension needs to:

- Resolve out-of-band state, for example a `tool_call` approval Promise, the moment a Telegram callback arrives, rather than waiting for the next agent turn.
- Suppress `pi-telegram`'s default routing for callbacks owned by the layered extension, so `pi-telegram` does not also forward them as `[callback] <data>` text.
- Observe arbitrary update types such as messages, edits, channel posts, or reactions without owning the polling connection.

If the layered extension only needs to read assistant-visible callbacks, the existing `[callback] <data>` fallback documented in [Callback Namespaces](./callback-namespaces.md) is enough.

If the extension needs a durable top-level Telegram menu section with managed rendering, callback routing, authorization, and diagnostics, use the higher-level [Telegram Extension Sections](./extension-sections.md) contract instead of a raw external handler.

## Constraints

- One bot, one pi process, one `getUpdates` poller. This registry does **not** enable running multiple pi instances against the same bot.
- Interceptors run in the polling loop. They must return quickly; long awaits delay subsequent updates.
- Interceptor errors are caught and logged silently so polling never breaks. If you need durable error reporting, do it inside your interceptor.
- The registry lives on `globalThis`. Module instance identity is not required, so layered extensions can reach it without importing `@llblab/pi-telegram`.

## Verdicts

Each interceptor returns one of:

- `"consume"` — `pi-telegram` skips its default routing for this update.
- `"pass"` or `void` / `undefined` — `pi-telegram` routes the update normally. Other interceptors registered after this one still run for the same update.

The first interceptor that returns `"consume"` wins; later interceptors are not called for that update.

## Registering an interceptor

Two equivalent paths.

### Typed import (recommended when you can depend on `@llblab/pi-telegram`)

```ts
import { onTelegramExternalUpdate } from "@llblab/pi-telegram/lib/external-handlers.ts";

const off = onTelegramExternalUpdate(async (update) => {
  const cb = (update as { callback_query?: { id?: string; data?: string } })
    .callback_query;
  if (!cb?.data?.startsWith("myext:")) return "pass";
  await resolveMyApproval(cb);
  return "consume";
});

// Later, when your extension shuts down:
off();
```

### Zero-coupling globalThis lookup

When the layered extension prefers no `import` from `@llblab/pi-telegram`, so load order between the two extensions does not matter and either can be installed first, it must implement the **full v1 registry contract**, not just `version` and `add`. pi-telegram's polling runtime calls `dispatch` on whatever object it finds at `globalThis.__piTelegramExternalHandlerRegistry__`, so a partial object would silently break the first update.

pi-telegram defensively re-creates the registry if the object on `globalThis` is missing `add` or `dispatch`, validated as `version === 1`, `typeof add === "function"`, and `typeof dispatch === "function"`. Handlers registered against a malformed object are dropped — make sure your bootstrap implements all three fields.

```ts
type PiTelegramVerdict =
  | "consume"
  | "pass"
  | void
  | Promise<"consume" | "pass" | void>;
type PiTelegramInterceptor = (update: unknown) => PiTelegramVerdict;

interface PiTelegramExternalHandlerRegistry {
  readonly version: 1;
  add: (handler: PiTelegramInterceptor) => () => void;
  // Required: pi-telegram's polling loop calls this on every update.
  dispatch: (update: unknown) => Promise<"consume" | "pass">;
}

const REGISTRY_KEY = "__piTelegramExternalHandlerRegistry__";

function getOrCreateRegistry(): PiTelegramExternalHandlerRegistry {
  const g = globalThis as Record<string, unknown>;
  const existing = g[REGISTRY_KEY] as
    | PiTelegramExternalHandlerRegistry
    | undefined;
  if (
    existing &&
    existing.version === 1 &&
    typeof existing.add === "function" &&
    typeof existing.dispatch === "function"
  ) {
    return existing;
  }
  const handlers = new Set<PiTelegramInterceptor>();
  const registry: PiTelegramExternalHandlerRegistry = {
    version: 1,
    add(handler) {
      handlers.add(handler);
      return () => handlers.delete(handler);
    },
    async dispatch(update) {
      for (const handler of handlers) {
        try {
          const result = await handler(update);
          if (result === "consume") return "consume";
        } catch {
          // Never break polling because of an interceptor error.
        }
      }
      return "pass";
    },
  };
  g[REGISTRY_KEY] = registry;
  return registry;
}

const off = getOrCreateRegistry().add((update) => {
  /* … */
  return "pass";
});
```

The registry object on `globalThis.__piTelegramExternalHandlerRegistry__` is versioned (`version: 1`) and stable across pi-telegram releases; future breaking changes will use a new schema version and a new key.

## Interaction with built-in routing

`pi-telegram` invokes registered interceptors first, then routes the update through its own handlers: commands, app menu, queue menu, model menu, default prompt routing, and callback namespace fallback. If any interceptor returns `"consume"`, `pi-telegram` skips the rest of routing for that update.

This means:

- Extensions can claim callback namespaces that `pi-telegram` would otherwise forward as `[callback] <data>` text.
- Extensions can observe updates by always returning `"pass"`.
- Extensions must not consume updates that belong to `pi-telegram`'s own prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`) unless they are deliberately replacing that behavior.

## Ownership semantics

The interceptor registry is ownership-agnostic and does not interact with the `locks.json` singleton lock documented in [Locks](./locks.md). When the locked polling runtime stops `pi-telegram`'s poller, for example after ownership is moved to another pi process, interceptors stop receiving updates because no updates are being fetched. They are not unregistered.

If a layered extension needs to react to ownership changes, it should observe `pi-telegram` lifecycle events through the standard pi extension hooks rather than through the interceptor registry.

## Not a multiplexer

This registry does not multiplex one bot across multiple pi processes, and it does not bypass Telegram's single-poller-per-bot constraint. To run multiple pi instances on Telegram, give each instance its own bot and its own `~/.pi/agent` directory; the registry is for layered extensions inside **one** pi process.

## Relationship to extension sections

External handlers are the raw update primitive. Extension sections are the structured Telegram UI layer above that primitive.

Use external handlers for immediate update interception, custom callback namespaces, out-of-band Promise resolution, and update types that should not become a Telegram menu surface.

Use extension sections when the desired behavior is a menu-integrated UI: `render(ctx)`, managed callback dispatch, safe runtime ports, stale-callback handling, and diagnostics owned by `pi-telegram`.
