[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M7EBTYAVBFKX3PY458GP0VCC":3,"skill-same:01M7EBTYAVBFKX3PY458GP0VCC":46},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":18,"publishedAt":19,"linkOnly":20,"markdownContent":21,"markdownBody":22,"frontMatter":23,"refName":24,"commitSha":25,"skillDirectory":26,"forksCount":27,"repositoryHtmlUrl":28,"repositoryHomepageUrl":6,"ownerHtmlUrl":29,"ownerBlogUrl":6,"ownerDescription":30,"updatedAt":31,"install":32,"resources":45},"01M7EBTYAVBFKX3PY458GP0VCC","channel-extension",null,"Use when adding a new messaging channel to Tel-Agent or changing an existing one — a transport under api\u002Fchannels\u002F, its official extension manifest, its settings card, tests, health and docs. Covers the file checklist, the two inbound shapes (dial-out and the public door), the credential and signature rules that are not negotiable, the declarative setup descriptor the generic card draws from, and the test shape every channel ships with.","lza6\u002FArmor-piercing-system-prompts","lza6","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F128880206?v=4","01M7EB3K7WPX5T28EJ2NVHE6EG","Chinese-tingfeng","01M7EB3K97E1KQ16QWKJMFNGX8",11,"MIT","https:\u002F\u002Fgithub.com\u002Flza6\u002FArmor-piercing-system-prompts\u002Fblob\u002Fmain\u002F03-逆向工程\u002Fchannel-extension\u002FSKILL.md",[],[],"2026-11-15 14:19:17",false,"---\nname: channel-extension\ndescription: Use when adding a new messaging channel to Tel-Agent or changing an existing one — a transport under api\u002Fchannels\u002F, its official extension manifest, its settings card, tests, health and docs. Covers the file checklist, the two inbound shapes (dial-out and the public door), the credential and signature rules that are not negotiable, the declarative setup descriptor the generic card draws from, and the test shape every channel ships with.\n---\n\n# Building a channel extension\n\nYou are adding a messaging channel to Tel-Agent. A channel is a route a **customer**\nuses to reach a business — someone is typing on the other end. If nobody outside the\nbusiness is on the other end, it is an integration, not a channel, and it is reached\nthrough the HTTP tool instead (Rule 5 in `CLAUDE.md`). Do not build it here.\n\n**Read first, in this order:** `CLAUDE.md` (the contract), `docs\u002FSPEC.md` §B9 (secrets)\nand §B13 (channels), then `api\u002Fchannels\u002Fdiscord.py` end to end. Discord is the model\nevery channel copies: a dial-out transport with a supervisor loop, an answering policy,\nstorage with dedup, and delivery-before-storage. For a channel that receives webhooks,\nalso read `api\u002Fchannels\u002Fmeta_chat.py` and `api\u002Froutes\u002Fmeta_chat_channels.py` — that is\nthe public door, and its guards are the whole of this product's exposed surface.\n\nWrite everything from the platform's **official documentation** and from the shape of\nthe existing modules. Write it fresh. Never paste code from another product into this\nrepository, never name another product anywhere in it, and never add a runtime\ndependency — `httpx`, `websockets`, `cryptography` and the standard library cover every\nplatform we ship.\n\n## What a channel consists of\n\nOne kind, one transport module, one manifest, one descriptor, one test file. In order:\n\n| # | Path | What goes there |\n|---|---|---|\n| 1 | `api\u002Fchannels\u002F\u003Ckind>.py` | The transport. Module surface below. |\n| 2 | `api\u002Fextensions\u002Fbuiltin\u002F\u003Ckind>.py` | `MANIFEST` + `register(context)`. Copy the Discord one; change slug, name, description. Category `channels`, origin `official`, scopes `conversations.write`, `messages.read`, `messages.write`, hooks `message.received`, ui_slots `conversation.detail`. |\n| 3 | `api\u002Fextensions\u002Fbuiltin\u002F__init__.py` | Append the module to `BUILTIN`. |\n| 4 | `api\u002Fmodels\u002Fconversation.py` | The kind is already in `CHANNEL_KINDS` (one migration added every kind of this wave). If yours is not, add it **and** an Alembic revision that widens the `channel_kind` CHECK constraint — `tests\u002Ftest_migrations.py` runs it on SQLite and PostgreSQL. |\n| 5 | `api\u002Fchannels\u002Fgeneric.py` | Add the module's import path to `_DECLARED`. The registry imports it on first read; that is what serves its card and its routes. |\n| 6 | `api\u002Fmain.py` | Dial-out channels: append `transport.loop(sessionmaker)` to `background`. Door channels: nothing — the generic public route dispatches by kind. |\n| 7 | `tests\u002Ftest_\u003Ckind>_channel.py` | The test file. Shape below. |\n| 8 | `web\u002Fcomponents\u002Fbrands\u002Fmarks.tsx` + `source\u002F\u003Ckind>.svg` | The mark, when the platform has a logo. Record the source and licence in `web\u002Fcomponents\u002Fbrands\u002FREADME.md`. A channel with no owner (SMS, IRC) gets a drawn glyph in `web\u002Fcomponents\u002Fshell\u002Fchannel-mark.tsx`. |\n| 9 | `web\u002Fapp\u002F[locale]\u002Fsettings\u002Fsettings.tsx` | Add the kind to `GENERIC_CHANNELS`. The card draws itself from the descriptor. |\n| 10 | `locales\u002Fen\u002Fweb.json` | The display name under `\"channels\"`. Other languages follow through the translation pipeline; do not touch thirty-four files. |\n| 11 | `docs\u002FSPEC.md` §B13, `README.md` icon row, `docs\u002Fbrand\u002Fchannels\u002F` | One row, one icon, one table line. |\n\n## The transport module surface\n\nEvery module exposes the same names, so the generic route, the health rollup and the\ntests can treat them alike. Signatures are the ones `api\u002Fchannels\u002Fdiscord.py` has;\nkeep them.\n\n```python\nKIND = \"\u003Ckind>\"\nSETUP = Setup(...)                                   # the descriptor, see below\nINBOUND = \"dial_out\"  # or \"door\"\n\ndef make_client() -> httpx.AsyncClient: ...          # tests monkeypatch this\nasync def probe(client, credentials: dict[str, str]) -> str: ...\n    # \"Test connection\". Returns the identity the platform reports (bot name, number,\n    # account) — the card shows it. Raises ChannelRefused on a bad credential.\nasync def send_text(client, credentials, target: str, text: str) -> None: ...\ndef message_text(event, identity) -> str | None: ... # the answering policy: None = ignore\nasync def ingest(db, channel, event) -> int | None: ...\n    # store one inbound line; None when it was a duplicate or not for us\nasync def respond(sessionmaker, channel_id: int, message_id: int) -> None: ...\ndef schedule_reply(sessionmaker, channel_id: int, message_id: int) -> None: ...\n\n# optional\ndef credentials_changed(channel_id: int) -> None: ...\n    # What the module is told after the operator writes this channel's fields, so it can\n    # drop anything it cached on the strength of the old ones — an access token bought\n    # with a secret that has just been replaced would otherwise keep working until it\n    # expired, which is a rotation that did not take effect. A module that caches nothing\n    # declares nothing; `api\u002Froutes\u002Fgeneric_channel.py` calls it only when it is there.\n\ndef reply_target(conversation) -> str | None: ...\n    # The address `send_text` needs for this thread, when it is not the conversation's\n    # own `external_id` — a room id, a thread key, a mailbox. Declare nothing and\n    # `generic.reply_target_of` falls back to `external_id`. It is what the human\n    # takeover route delivers to as well, so a channel that answers somewhere other\n    # than where the customer wrote from owes this one function and nothing else.\n\n# dial-out only\nasync def loop(sessionmaker) -> None: ...            # the supervisor, reconciles every 15 s\n\n# door only\nasync def receive(db, channel, request: Request) -> Response: ...\n    # verify, dedup, store, schedule, acknowledge. One refusal for every failure.\n```\n\n`credentials` is the decrypted JSON of `Channel.credentials_encrypted` — a dict keyed by\nthe descriptor's field names. Never read it on the inbound hot path for anything other\nthan a signature check; per-channel state that every message needs lives in\n`settings_json`, which is plain.\n\n### Shared helpers — use these, do not copy Discord\n\n`api\u002Fchannels\u002Fgeneric.py` holds the half of a transport that is the same on every\nchannel. A declarative channel writes the platform-specific half only: its descriptor,\nits signature check, `probe`, `send_text`, `message_text`, `ingest`, and its `receive`\nor `loop`. `split_text` is a line delegating to `split_on_words` below. Everything below\nis already written.\n\n| Helper | What it does |\n|---|---|\n| `channels()` | The registry, populated on first read. `module_for(kind)` and `dial_out_modules()` go through it, so no import order can change what an installation has. Never read `CHANNELS` directly. |\n| `public_url_for(request, settings)` | The address the platform called, for a signature computed over it. `PUBLIC_BASE_URL` when the installation has one, else the forwarded headers — the one place in this product where those are read, and the reasoning is in the function. The settings card builds its `webhook_url` on the same base, so the two cannot disagree. |\n| `conversation_for(db, channel, external_id, title=None)` | This customer's open thread, and whether it was just started. |\n| `store_line(db, conversation, role, text, language=None)` | One line into the transcript. |\n| `seen_before(conversation, kind, message_id)` | Dedup. A ring of the last 32 platform ids in `state_json[kind]`, because a platform does not retry in order. |\n| `announce(db, channel, conversation, message, started)` | The `conversation.started` and `message.received` hooks. |\n| `preview(text)` | One line of a customer's words, for a notification. |\n| `reply_target_of(module, conversation)` | Where a reply goes: the module's `reply_target`, else `external_id`. |\n| `split_on_words(text, limit)` | The cut every channel shares: pieces no longer than `limit`, never inside a word, never silently short, and a single word longer than the limit cut rather than dropped. A module's `split_text` is one line handing this its own `MESSAGE_MAX`. |\n| `deliver(module, client, credentials, target, text)` | One answer out, cut into the platform's messages through the module's `split_text`. |\n| `schedule_reply(sessionmaker, module, channel_id, message_id)` | The answer as its own task, held so it cannot be collected mid-reply. |\n| `respond(sessionmaker, module, channel_id, message_id)` | The whole answer path: its own session, takeover read before generating **and again before sending**, delivery before storage, health timing. |\n\nA module still exposes `respond` and `schedule_reply` itself — the registry requires\nthem — but each is one line handing the shared one this module:\n\n```python\ndef _self() -> ModuleType:\n    return sys.modules[__name__]\n\nasync def respond(sessionmaker, channel_id: int, message_id: int) -> None:\n    await generic.respond(sessionmaker, _self(), channel_id, message_id)\n```\n\nThe human takeover route needs no branch per channel: `api\u002Froutes\u002Fconversations.py`\ndelivers through the registry for any kind on it.\n\n## The setup descriptor\n\nThe card, the write route and the test button are generic. What makes one channel's\ncard differ from another's is this object, declared once in the transport module:\n\n```python\nfrom api.channels.setup import Field, Setup\n\nSETUP = Setup(\n    kind=\"line\",\n    title=\"LINE\",\n    note=\"An official account from your own LINE Developers console answers people who message it.\",\n    guide_url=\"https:\u002F\u002Fdevelopers.line.biz\u002Fen\u002Fdocs\u002Fmessaging-api\u002Fgetting-started\u002F\",\n    fields=(\n        Field(\"channel_secret\", \"Channel secret\", secret=True,\n              help=\"From the Basic settings tab of your channel.\"),\n        Field(\"channel_access_token\", \"Channel access token\", secret=True,\n              help=\"Issue a long-lived token on the Messaging API tab.\"),\n    ),\n    verified_live=False,   # True once a real message has gone through on a customer's account\n)\n```\n\n- `secret=True` fields are write-only: the API returns a masked preview (last four\n  characters), never the value. `secret=False` fields are returned as stored.\n- `required=False` fields may be left empty. The card shows them under \"Optional\".\n- The English `label` and `help` are what the card shows unless a locale key\n  `ch_\u003Ckind>_\u003Cfield>` \u002F `ch_\u003Ckind>_\u003Cfield>_help` exists. Do not add locale keys for a\n  new channel yourself; the translation pipeline does.\n- `verified_live=False` prints \"built against the published API, not yet verified with\n  a live account\" on the card. It is honest and it stays until someone flips it after\n  a real message.\n- A door channel's card also shows its public URL, built from `Channel.webhook_path`.\n\nThe generic routes are `GET \u002Fapi\u002Fchannels\u002F{kind}` (descriptor + state),\n`PUT \u002Fapi\u002Fchannels\u002F{kind}` (fields, `enabled`), `POST \u002Fapi\u002Fchannels\u002F{kind}\u002Ftest`, and for\ndoors `GET|POST \u002Fpublic\u002F{kind}\u002F{webhook_path}`. They live in\n`api\u002Froutes\u002Fgeneric_channel.py` and `api\u002Froutes\u002Fpublic_channel.py`; you do not write\nroutes for a new channel.\n\n## Rules that are not negotiable\n\n**Credentials — §B9.** Encrypted at rest in `Channel.credentials_encrypted`, one JSON\nobject for all of a channel's secrets. Write-only: an empty string clears the field, a\nvalue starting with `•` or `*` is an echoed mask and is ignored, anything else is stored.\nClearing the last required secret switches the channel off. Storing requires the\ninstallation's encryption key (409 `encryption_key_missing` without it). Never log a\ncredential, never return one, never put one in a URL. Every write is audited.\n\n**The customer's own application.** Tel-Agent holds no shared platform app. Every\ncredential comes from the customer's own developer account. If a platform only works\nthrough an application the vendor owns, it cannot be a Tel-Agent channel.\n\n**The door — for every channel that receives webhooks.**\n1. The address is a long random path (`secrets.token_urlsafe(24)`), unique per channel,\n   stored in `Channel.webhook_path`. The route is `\u002Fpublic\u002F{kind}\u002F{webhook_path}`.\n2. Verify the platform's signature over the **raw body bytes**, before parsing anything,\n   with `hmac.compare_digest`. A JWT is verified against the issuer's published keys\n   with `cryptography`, checking `iss`, `aud`, `exp`. No signature scheme, no channel.\n3. **One refusal for every reason.** Unknown path, wrong signature, disabled channel,\n   malformed body — all of them return the same 403 `not_recognised`. The door leaks\n   nothing about what exists behind it.\n4. Acknowledge first, answer after. Return the platform's expected 200 immediately;\n   generate and deliver the reply in a scheduled task. Platforms retry slow webhooks and\n   you will answer twice.\n5. Dedup by the platform's own message id before storing. Retries are normal.\n6. Signal, Matrix, Mattermost, IRC, iMessage and every other dial-out channel expose\n   nothing. Prefer dial-out whenever the platform offers it; the LAN installation is the\n   product's reason to exist.\n\n**Answering policy.** A direct message is always answered. A group, room, channel or\nspace message is answered only when the bot is addressed, and the mention is stripped\nbefore the text reaches the model. A message from the bot itself, or from another bot,\nis never answered.\n\n**Delivery before storage.** `respond()` generates the whole reply, delivers it, and\nonly then writes the agent line. If delivery raises, nothing is written — a transcript\nmust never show an answer the customer did not receive. Human takeover is checked\nbefore generating and again before sending.\n\n**Reconnection.** Dial-out loops reconnect with exponential backoff from 5 s to 300 s\nand report their state through `api\u002Fchannels\u002Fhealth.py` so the health page can say\n\"connected\" or \"reconnecting\" truthfully.\n\n**Text limits.** Every platform has one. Split on word boundaries at the platform's\nlimit; never truncate silently.\n\n**Nothing new in `pyproject.toml`.** If you believe a platform genuinely cannot be done\nwithout an SDK, stop and say so in the report instead of adding it.\n\n## The test file\n\n`tests\u002Ftest_\u003Ckind>_channel.py`, modelled on `tests\u002Ftest_discord_channel.py`. A fake\nplatform, not mocked functions:\n\n```python\nclass FakeLine:\n    def __init__(self) -> None:\n        self.sent: list[dict] = []\n        self.refuse = False\n    def handler(self, request: httpx.Request) -> httpx.Response: ...   # route by path\n    def client(self) -> httpx.AsyncClient:\n        return httpx.AsyncClient(base_url=\"https:\u002F\u002Fline.test\",\n                                 transport=httpx.MockTransport(self.handler))\n```\n\nThe `stage` fixture builds a workspace, a channel row with credentials, three users at\n`admin` \u002F `reception` \u002F `viewer`, monkeypatches `make_client`, and starts the real app.\nCopy it.\n\nEvery channel's file has at least these, named for what they prove:\n\n- a direct message is answered, and a message from a bot never is\n- a group message is answered only when addressed, and the mention is stripped\n- a repeated event is dropped by the platform's own id\n- a taken-over thread gets no generated reply\n- a refused send leaves no agent line in the record\n- the secret goes in and only a mask comes out\n- removing the secret switches the channel off with it\n- a viewer reads and never writes\n- the test button reports the platform's identity, and reports refusal\n- **door channels:** a wrong signature, an unknown path and a disabled channel all get\n  the same refusal; the platform's verification handshake succeeds\n- **dial-out channels:** the loop starts a connection for an active channel and stops\n  it when the channel is disabled\n\nRun the suite for your file and the shared ones, then the gate:\n\n```bash\npytest tests\u002Ftest_\u003Ckind>_channel.py tests\u002Ftest_channel_health.py tests\u002Ftest_extensions.py -q\nruff check . && ruff format --check . && mypy .\nnpm --prefix web run lint && npx --prefix web tsc --noEmit\n```\n\n## Before the commit\n\n- No secret, no real phone number, no product other than the platform itself named.\n- English only. Conventional commit: `feat(channels): add \u003Ckind>`.\n- The card renders with no credentials saved, with a secret saved (mask shown), and\n  after a test — check all three in the browser before calling it done.\n","# Building a channel extension\n\nYou are adding a messaging channel to Tel-Agent. A channel is a route a **customer**\nuses to reach a business — someone is typing on the other end. If nobody outside the\nbusiness is on the other end, it is an integration, not a channel, and it is reached\nthrough the HTTP tool instead (Rule 5 in `CLAUDE.md`). Do not build it here.\n\n**Read first, in this order:** `CLAUDE.md` (the contract), `docs\u002FSPEC.md` §B9 (secrets)\nand §B13 (channels), then `api\u002Fchannels\u002Fdiscord.py` end to end. Discord is the model\nevery channel copies: a dial-out transport with a supervisor loop, an answering policy,\nstorage with dedup, and delivery-before-storage. For a channel that receives webhooks,\nalso read `api\u002Fchannels\u002Fmeta_chat.py` and `api\u002Froutes\u002Fmeta_chat_channels.py` — that is\nthe public door, and its guards are the whole of this product's exposed surface.\n\nWrite everything from the platform's **official documentation** and from the shape of\nthe existing modules. Write it fresh. Never paste code from another product into this\nrepository, never name another product anywhere in it, and never add a runtime\ndependency — `httpx`, `websockets`, `cryptography` and the standard library cover every\nplatform we ship.\n\n## What a channel consists of\n\nOne kind, one transport module, one manifest, one descriptor, one test file. In order:\n\n| # | Path | What goes there |\n|---|---|---|\n| 1 | `api\u002Fchannels\u002F\u003Ckind>.py` | The transport. Module surface below. |\n| 2 | `api\u002Fextensions\u002Fbuiltin\u002F\u003Ckind>.py` | `MANIFEST` + `register(context)`. Copy the Discord one; change slug, name, description. Category `channels`, origin `official`, scopes `conversations.write`, `messages.read`, `messages.write`, hooks `message.received`, ui_slots `conversation.detail`. |\n| 3 | `api\u002Fextensions\u002Fbuiltin\u002F__init__.py` | Append the module to `BUILTIN`. |\n| 4 | `api\u002Fmodels\u002Fconversation.py` | The kind is already in `CHANNEL_KINDS` (one migration added every kind of this wave). If yours is not, add it **and** an Alembic revision that widens the `channel_kind` CHECK constraint — `tests\u002Ftest_migrations.py` runs it on SQLite and PostgreSQL. |\n| 5 | `api\u002Fchannels\u002Fgeneric.py` | Add the module's import path to `_DECLARED`. The registry imports it on first read; that is what serves its card and its routes. |\n| 6 | `api\u002Fmain.py` | Dial-out channels: append `transport.loop(sessionmaker)` to `background`. Door channels: nothing — the generic public route dispatches by kind. |\n| 7 | `tests\u002Ftest_\u003Ckind>_channel.py` | The test file. Shape below. |\n| 8 | `web\u002Fcomponents\u002Fbrands\u002Fmarks.tsx` + `source\u002F\u003Ckind>.svg` | The mark, when the platform has a logo. Record the source and licence in `web\u002Fcomponents\u002Fbrands\u002FREADME.md`. A channel with no owner (SMS, IRC) gets a drawn glyph in `web\u002Fcomponents\u002Fshell\u002Fchannel-mark.tsx`. |\n| 9 | `web\u002Fapp\u002F[locale]\u002Fsettings\u002Fsettings.tsx` | Add the kind to `GENERIC_CHANNELS`. The card draws itself from the descriptor. |\n| 10 | `locales\u002Fen\u002Fweb.json` | The display name under `\"channels\"`. Other languages follow through the translation pipeline; do not touch thirty-four files. |\n| 11 | `docs\u002FSPEC.md` §B13, `README.md` icon row, `docs\u002Fbrand\u002Fchannels\u002F` | One row, one icon, one table line. |\n\n## The transport module surface\n\nEvery module exposes the same names, so the generic route, the health rollup and the\ntests can treat them alike. Signatures are the ones `api\u002Fchannels\u002Fdiscord.py` has;\nkeep them.\n\n```python\nKIND = \"\u003Ckind>\"\nSETUP = Setup(...)                                   # the descriptor, see below\nINBOUND = \"dial_out\"  # or \"door\"\n\ndef make_client() -> httpx.AsyncClient: ...          # tests monkeypatch this\nasync def probe(client, credentials: dict[str, str]) -> str: ...\n    # \"Test connection\". Returns the identity the platform reports (bot name, number,\n    # account) — the card shows it. Raises ChannelRefused on a bad credential.\nasync def send_text(client, credentials, target: str, text: str) -> None: ...\ndef message_text(event, identity) -> str | None: ... # the answering policy: None = ignore\nasync def ingest(db, channel, event) -> int | None: ...\n    # store one inbound line; None when it was a duplicate or not for us\nasync def respond(sessionmaker, channel_id: int, message_id: int) -> None: ...\ndef schedule_reply(sessionmaker, channel_id: int, message_id: int) -> None: ...\n\n# optional\ndef credentials_changed(channel_id: int) -> None: ...\n    # What the module is told after the operator writes this channel's fields, so it can\n    # drop anything it cached on the strength of the old ones — an access token bought\n    # with a secret that has just been replaced would otherwise keep working until it\n    # expired, which is a rotation that did not take effect. A module that caches nothing\n    # declares nothing; `api\u002Froutes\u002Fgeneric_channel.py` calls it only when it is there.\n\ndef reply_target(conversation) -> str | None: ...\n    # The address `send_text` needs for this thread, when it is not the conversation's\n    # own `external_id` — a room id, a thread key, a mailbox. Declare nothing and\n    # `generic.reply_target_of` falls back to `external_id`. It is what the human\n    # takeover route delivers to as well, so a channel that answers somewhere other\n    # than where the customer wrote from owes this one function and nothing else.\n\n# dial-out only\nasync def loop(sessionmaker) -> None: ...            # the supervisor, reconciles every 15 s\n\n# door only\nasync def receive(db, channel, request: Request) -> Response: ...\n    # verify, dedup, store, schedule, acknowledge. One refusal for every failure.\n```\n\n`credentials` is the decrypted JSON of `Channel.credentials_encrypted` — a dict keyed by\nthe descriptor's field names. Never read it on the inbound hot path for anything other\nthan a signature check; per-channel state that every message needs lives in\n`settings_json`, which is plain.\n\n### Shared helpers — use these, do not copy Discord\n\n`api\u002Fchannels\u002Fgeneric.py` holds the half of a transport that is the same on every\nchannel. A declarative channel writes the platform-specific half only: its descriptor,\nits signature check, `probe`, `send_text`, `message_text`, `ingest`, and its `receive`\nor `loop`. `split_text` is a line delegating to `split_on_words` below. Everything below\nis already written.\n\n| Helper | What it does |\n|---|---|\n| `channels()` | The registry, populated on first read. `module_for(kind)` and `dial_out_modules()` go through it, so no import order can change what an installation has. Never read `CHANNELS` directly. |\n| `public_url_for(request, settings)` | The address the platform called, for a signature computed over it. `PUBLIC_BASE_URL` when the installation has one, else the forwarded headers — the one place in this product where those are read, and the reasoning is in the function. The settings card builds its `webhook_url` on the same base, so the two cannot disagree. |\n| `conversation_for(db, channel, external_id, title=None)` | This customer's open thread, and whether it was just started. |\n| `store_line(db, conversation, role, text, language=None)` | One line into the transcript. |\n| `seen_before(conversation, kind, message_id)` | Dedup. A ring of the last 32 platform ids in `state_json[kind]`, because a platform does not retry in order. |\n| `announce(db, channel, conversation, message, started)` | The `conversation.started` and `message.received` hooks. |\n| `preview(text)` | One line of a customer's words, for a notification. |\n| `reply_target_of(module, conversation)` | Where a reply goes: the module's `reply_target`, else `external_id`. |\n| `split_on_words(text, limit)` | The cut every channel shares: pieces no longer than `limit`, never inside a word, never silently short, and a single word longer than the limit cut rather than dropped. A module's `split_text` is one line handing this its own `MESSAGE_MAX`. |\n| `deliver(module, client, credentials, target, text)` | One answer out, cut into the platform's messages through the module's `split_text`. |\n| `schedule_reply(sessionmaker, module, channel_id, message_id)` | The answer as its own task, held so it cannot be collected mid-reply. |\n| `respond(sessionmaker, module, channel_id, message_id)` | The whole answer path: its own session, takeover read before generating **and again before sending**, delivery before storage, health timing. |\n\nA module still exposes `respond` and `schedule_reply` itself — the registry requires\nthem — but each is one line handing the shared one this module:\n\n```python\ndef _self() -> ModuleType:\n    return sys.modules[__name__]\n\nasync def respond(sessionmaker, channel_id: int, message_id: int) -> None:\n    await generic.respond(sessionmaker, _self(), channel_id, message_id)\n```\n\nThe human takeover route needs no branch per channel: `api\u002Froutes\u002Fconversations.py`\ndelivers through the registry for any kind on it.\n\n## The setup descriptor\n\nThe card, the write route and the test button are generic. What makes one channel's\ncard differ from another's is this object, declared once in the transport module:\n\n```python\nfrom api.channels.setup import Field, Setup\n\nSETUP = Setup(\n    kind=\"line\",\n    title=\"LINE\",\n    note=\"An official account from your own LINE Developers console answers people who message it.\",\n    guide_url=\"https:\u002F\u002Fdevelopers.line.biz\u002Fen\u002Fdocs\u002Fmessaging-api\u002Fgetting-started\u002F\",\n    fields=(\n        Field(\"channel_secret\", \"Channel secret\", secret=True,\n              help=\"From the Basic settings tab of your channel.\"),\n        Field(\"channel_access_token\", \"Channel access token\", secret=True,\n              help=\"Issue a long-lived token on the Messaging API tab.\"),\n    ),\n    verified_live=False,   # True once a real message has gone through on a customer's account\n)\n```\n\n- `secret=True` fields are write-only: the API returns a masked preview (last four\n  characters), never the value. `secret=False` fields are returned as stored.\n- `required=False` fields may be left empty. The card shows them under \"Optional\".\n- The English `label` and `help` are what the card shows unless a locale key\n  `ch_\u003Ckind>_\u003Cfield>` \u002F `ch_\u003Ckind>_\u003Cfield>_help` exists. Do not add locale keys for a\n  new channel yourself; the translation pipeline does.\n- `verified_live=False` prints \"built against the published API, not yet verified with\n  a live account\" on the card. It is honest and it stays until someone flips it after\n  a real message.\n- A door channel's card also shows its public URL, built from `Channel.webhook_path`.\n\nThe generic routes are `GET \u002Fapi\u002Fchannels\u002F{kind}` (descriptor + state),\n`PUT \u002Fapi\u002Fchannels\u002F{kind}` (fields, `enabled`), `POST \u002Fapi\u002Fchannels\u002F{kind}\u002Ftest`, and for\ndoors `GET|POST \u002Fpublic\u002F{kind}\u002F{webhook_path}`. They live in\n`api\u002Froutes\u002Fgeneric_channel.py` and `api\u002Froutes\u002Fpublic_channel.py`; you do not write\nroutes for a new channel.\n\n## Rules that are not negotiable\n\n**Credentials — §B9.** Encrypted at rest in `Channel.credentials_encrypted`, one JSON\nobject for all of a channel's secrets. Write-only: an empty string clears the field, a\nvalue starting with `•` or `*` is an echoed mask and is ignored, anything else is stored.\nClearing the last required secret switches the channel off. Storing requires the\ninstallation's encryption key (409 `encryption_key_missing` without it). Never log a\ncredential, never return one, never put one in a URL. Every write is audited.\n\n**The customer's own application.** Tel-Agent holds no shared platform app. Every\ncredential comes from the customer's own developer account. If a platform only works\nthrough an application the vendor owns, it cannot be a Tel-Agent channel.\n\n**The door — for every channel that receives webhooks.**\n1. The address is a long random path (`secrets.token_urlsafe(24)`), unique per channel,\n   stored in `Channel.webhook_path`. The route is `\u002Fpublic\u002F{kind}\u002F{webhook_path}`.\n2. Verify the platform's signature over the **raw body bytes**, before parsing anything,\n   with `hmac.compare_digest`. A JWT is verified against the issuer's published keys\n   with `cryptography`, checking `iss`, `aud`, `exp`. No signature scheme, no channel.\n3. **One refusal for every reason.** Unknown path, wrong signature, disabled channel,\n   malformed body — all of them return the same 403 `not_recognised`. The door leaks\n   nothing about what exists behind it.\n4. Acknowledge first, answer after. Return the platform's expected 200 immediately;\n   generate and deliver the reply in a scheduled task. Platforms retry slow webhooks and\n   you will answer twice.\n5. Dedup by the platform's own message id before storing. Retries are normal.\n6. Signal, Matrix, Mattermost, IRC, iMessage and every other dial-out channel expose\n   nothing. Prefer dial-out whenever the platform offers it; the LAN installation is the\n   product's reason to exist.\n\n**Answering policy.** A direct message is always answered. A group, room, channel or\nspace message is answered only when the bot is addressed, and the mention is stripped\nbefore the text reaches the model. A message from the bot itself, or from another bot,\nis never answered.\n\n**Delivery before storage.** `respond()` generates the whole reply, delivers it, and\nonly then writes the agent line. If delivery raises, nothing is written — a transcript\nmust never show an answer the customer did not receive. Human takeover is checked\nbefore generating and again before sending.\n\n**Reconnection.** Dial-out loops reconnect with exponential backoff from 5 s to 300 s\nand report their state through `api\u002Fchannels\u002Fhealth.py` so the health page can say\n\"connected\" or \"reconnecting\" truthfully.\n\n**Text limits.** Every platform has one. Split on word boundaries at the platform's\nlimit; never truncate silently.\n\n**Nothing new in `pyproject.toml`.** If you believe a platform genuinely cannot be done\nwithout an SDK, stop and say so in the report instead of adding it.\n\n## The test file\n\n`tests\u002Ftest_\u003Ckind>_channel.py`, modelled on `tests\u002Ftest_discord_channel.py`. A fake\nplatform, not mocked functions:\n\n```python\nclass FakeLine:\n    def __init__(self) -> None:\n        self.sent: list[dict] = []\n        self.refuse = False\n    def handler(self, request: httpx.Request) -> httpx.Response: ...   # route by path\n    def client(self) -> httpx.AsyncClient:\n        return httpx.AsyncClient(base_url=\"https:\u002F\u002Fline.test\",\n                                 transport=httpx.MockTransport(self.handler))\n```\n\nThe `stage` fixture builds a workspace, a channel row with credentials, three users at\n`admin` \u002F `reception` \u002F `viewer`, monkeypatches `make_client`, and starts the real app.\nCopy it.\n\nEvery channel's file has at least these, named for what they prove:\n\n- a direct message is answered, and a message from a bot never is\n- a group message is answered only when addressed, and the mention is stripped\n- a repeated event is dropped by the platform's own id\n- a taken-over thread gets no generated reply\n- a refused send leaves no agent line in the record\n- the secret goes in and only a mask comes out\n- removing the secret switches the channel off with it\n- a viewer reads and never writes\n- the test button reports the platform's identity, and reports refusal\n- **door channels:** a wrong signature, an unknown path and a disabled channel all get\n  the same refusal; the platform's verification handshake succeeds\n- **dial-out channels:** the loop starts a connection for an active channel and stops\n  it when the channel is disabled\n\nRun the suite for your file and the shared ones, then the gate:\n\n```bash\npytest tests\u002Ftest_\u003Ckind>_channel.py tests\u002Ftest_channel_health.py tests\u002Ftest_extensions.py -q\nruff check . && ruff format --check . && mypy .\nnpm --prefix web run lint && npx --prefix web tsc --noEmit\n```\n\n## Before the commit\n\n- No secret, no real phone number, no product other than the platform itself named.\n- English only. Conventional commit: `feat(channels): add \u003Ckind>`.\n- The card renders with no credentials saved, with a secret saved (mask shown), and\n  after a test — check all three in the browser before calling it done.\n",{"name":5,"description":7},"main","d69096e239f2a89aabc2cd078c47664a9d3b1e11","03-逆向工程\u002Fchannel-extension",3,"https:\u002F\u002Fgithub.com\u002Flza6\u002FArmor-piercing-system-prompts","https:\u002F\u002Fgithub.com\u002Flza6","个人vx：Tf00798\r\n个人✈：@TFGY999","2026-10-08 14:29:22",{"source":8,"skillName":5,"commands":33,"prompt":43,"zipAvailable":44},[34,37,40],{"tool":35,"command":36},"npx","npx skills add lza6\u002FArmor-piercing-system-prompts --skill channel-extension",{"tool":38,"command":39},"bunx","bunx skills add lza6\u002FArmor-piercing-system-prompts --skill channel-extension",{"tool":41,"command":42},"pnpm","pnpm dlx skills add lza6\u002FArmor-piercing-system-prompts --skill channel-extension","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Flza6\u002FArmor-piercing-system-prompts\u002Fblob\u002Fmain\u002F03-逆向工程\u002Fchannel-extension\u002FSKILL.md",true,[],[47,54,61,68,76,83],{"skillNo":48,"name":49,"nameZh":6,"description":50,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":51,"categories":52,"keywords":53,"publishedAt":19},"01M7EXRM12046G0GM1WGMMFXCN","ci-cd-and-automation","Automates CI\u002FCD pipeline setup. Use when setting up or modifying build and deployment pipelines. Use when you need to automate quality gates, configure test runners in CI, or establish deployment strategies.","https:\u002F\u002Fgithub.com\u002Flza6\u002FArmor-piercing-system-prompts\u002Fblob\u002Fmain\u002F05-技能与代理开发\u002Fci-cd-and-automation\u002FSKILL.md",[],[],{"skillNo":55,"name":56,"nameZh":6,"description":57,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":58,"categories":59,"keywords":60,"publishedAt":19},"01M7EXRFYYSJ3Y15AGPDDEWWQD","chrome-devtools","Uses Chrome DevTools via MCP for efficient debugging, troubleshooting and browser automation. Use when debugging web pages, automating browser interactions, analyzing performance, or inspecting network requests. This skill does not apply to `--slim` mode (MCP configuration).","https:\u002F\u002Fgithub.com\u002Flza6\u002FArmor-piercing-system-prompts\u002Fblob\u002Fmain\u002F05-技能与代理开发\u002Fchrome-devtools\u002FSKILL.md",[],[],{"skillNo":62,"name":63,"nameZh":6,"description":64,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":65,"categories":66,"keywords":67,"publishedAt":19},"01M7EXR6EYPE4X5X1BTAE6QJRV","chrome-cdp","Drive a real Chrome via the Chrome DevTools Protocol (CDP). Ensures a\ndebuggable Chrome is running (launching it if needed) so the agent can\nnavigate, read page content, inspect the DOM, run JavaScript, and take\nscreenshots — without the user typing any launch command. Includes a\nfirst-class \"review a live running app\" flow: point Chrome at the app under\ndevelopment and inspect\u002Fscreenshot the REAL page (what a static-artifact tool\ncannot do). Backed by Glimpse and the chrome-devtools MCP server. Use when\nasked to \"use chrome\", \"open chrome\", \"drive my browser\", \"read the page in\nchrome\", \"review the running app\", \"screenshot the app\", or \"\u002Fchrome-cdp\".","https:\u002F\u002Fgithub.com\u002Flza6\u002FArmor-piercing-system-prompts\u002Fblob\u002Fmain\u002F05-技能与代理开发\u002Fchrome-cdp\u002FSKILL.md",[],[],{"skillNo":69,"name":70,"nameZh":6,"description":71,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":72,"licenseFileUrl":6,"sourceHtmlUrl":73,"categories":74,"keywords":75,"publishedAt":19},"01M7EXR1Y7E79KQKG4J798XGH6","check-identity-pack","Run an AFP 100-point or AUSTRAC safe-harbour identity check over a set of documents, and report exactly what's missing. Use when the user asks to check identity documents, verify someone's ID for onboarding, or assess whether a document pack satisfies Australian identity requirements.","Apache-2.0","https:\u002F\u002Fgithub.com\u002Flza6\u002FArmor-piercing-system-prompts\u002Fblob\u002Fmain\u002F05-技能与代理开发\u002Fcheck-identity-pack\u002FSKILL.md",[],[],{"skillNo":77,"name":78,"nameZh":6,"description":79,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":80,"categories":81,"keywords":82,"publishedAt":19},"01M7EXQXW7C7PGREJW9ZZTZ16H","changelog","Auto-generates a changelog from git commits, sprint data, and design documents. Produces both internal and player-facing versions.","https:\u002F\u002Fgithub.com\u002Flza6\u002FArmor-piercing-system-prompts\u002Fblob\u002Fmain\u002F05-技能与代理开发\u002Fchangelog\u002FSKILL.md",[],[],{"skillNo":84,"name":85,"nameZh":6,"description":86,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":87,"categories":88,"keywords":89,"publishedAt":19},"01M7EXQMH2ZME3ANN8Q8MEWPFB","cellxgene-census","Query the CZ CELLxGENE Census programmatically for versioned public single-cell and spatial transcriptomics data. Use when you need population-scale cell metadata, gene expression slices, Census summary counts, source H5AD URIs\u002Fdownloads, embeddings, spatial Census data, or reference atlas comparisons across organisms, tissues, diseases, assays, and cell types. For analyzing your own local single-cell data use scanpy, anndata, or scvi-tools.","https:\u002F\u002Fgithub.com\u002Flza6\u002FArmor-piercing-system-prompts\u002Fblob\u002Fmain\u002F05-技能与代理开发\u002Fcellxgene-census\u002FSKILL.md",[],[]]