This cookbook shows how to extend dsh-superagent. It uses the terms in 001 Domain model and 002 Authorization, and the components in 007 Architecture.
This recipe implements a Source kind called webhook. It reads an imaginary message queue through an invented CLI, hookq, and converts messages to Triggers. All other interfaces are part of dsh-superagent.
The implementation has four responsibilities: translate events, define receiving and authorization rules, provide replies and context, and maintain a polling loop. The numbered steps build those pieces in order.
The example assumes this CLI contract:
hookq pull --after <n> --jsonprints the messages after positionn, oldest first:{"me": "<the agent's id on the queue>", "items": […], "next": <position>}. An item hasid,conversation,thread(the id of the message that started its thread, absent on that message),senderwithidandbot,mentions_me,text,atandurl.hookq reply --conversation <id> --to <message id> --key <key> --text <text>replies under a message, once per key. For a conversation that was deleted it fails with{"error": {"code": 410, "message": "…"}}on standard error.hookq thread --conversation <id> --root <message id> --json, with--after <cursor>for a later page, prints a page of a thread, oldest first:{"items": […], "next": "<cursor>"}, withoutnexton the last page.
Put the kind and its plugin entry in src/sources/webhook.ts, following src/sources/github.ts. If the Source grows, split translation, Channel and polling code into separate modules as the Feishu Source does.
Choose stable mappings before writing code. Trigger ids, Thread ids and Channel addresses are persisted, so changing them later can disconnect existing records.
| Concept | For the webhook queue |
|---|---|
| Trigger | One message |
| Thread | A message and the replies to it in one conversation: <conversation>:<id of the first message> |
| Actor | The sender's id on the queue, as the allowed list names it |
| Channel address | The conversation and the message a reply goes under |
| Receiving scope | A conversation, with its own row in the admission table |
The module imports what the steps below use:
import type { Context } from '@deepseek-ai/cordis'
import { setTimeout as delay } from 'node:timers/promises'
import { z } from 'zod'
import { key, threadKey, type OutboundMessage, type Trigger } from '../core/index.ts'
import { CliError } from '../infra/commands.ts'
import { KV } from '../kv.ts'
import { health, sourceContext, type SourceContext } from './host.ts'
import type { SourceKind } from './kind.ts'const ConfigSchema = z.object({
conversations: z.array(z.object({
id: z.string().regex(/^[a-z0-9-]+$/),
listen: z.enum(['mentions', 'all']),
}).strict()).default([]),
pollSeconds: z.number().int().min(10).max(3600).default(30),
}).strict()
export type WebhookConfig = z.infer<typeof ConfigSchema>
export const webhookKind: SourceKind<WebhookConfig> = {
kind: 'webhook',
title: { zh: 'Webhook 队列', en: 'Webhook queue' },
description: { zh: '接收 webhook 队列里下列对话的消息。', en: 'Receives the messages of these conversations from the webhook queue.' },
schema: ConfigSchema,
fields: [
{ key: 'conversations', type: 'rows', label: { zh: '接收的对话', en: 'Conversations' }, columns: [
{ key: 'id', type: 'text', label: { zh: '对话', en: 'Conversation' }, pattern: '[a-z0-9-]+' },
{ key: 'listen', type: 'select', label: { zh: '接收', en: 'Receive' }, options: [
{ value: 'mentions', label: { zh: '仅 @', en: '@ only' } }, { value: 'all', label: { zh: '全部消息', en: 'All' } }] },
] },
{ key: 'pollSeconds', type: 'number', advanced: true, min: 10, max: 3600,
label: { zh: '轮询间隔 / 秒', en: 'Polling interval / seconds' } },
],
health: ['webhook'],
// find, scope, channel, threadContext, policy and environment follow in the next steps.
}kindis the stable Source id. It appears in Triggersource, Thread-key prefixes, thesource:webhooksettings block and the Source Policy.schemavalidates stored configuration and supplies defaults. Before an operator saves an instance, the registry creates an enabled instance from those defaults. Make defaults receive nothing; this example defaults to no conversations.fieldsgenerates the Source settings form without custom panel code. Arowsfield edits entries keyed by its first column;advancedhides less common settings. ImplementrefreshLabelsif the Source can resolve ids to display names. Save those names underKV.sources.labels(kind). Columns withchooseandrefreshcan then offer them.- Optional
facts(deployment)displays values derived by setup, such as the account behind a CLI. Setup currently checks only GitHub and Feishu fields listed inSETUP_FIELDS, so this example supplies no facts. healthdeclares the Source's health entries, initiallystarting. Registration claims each name and refuses collisions with entries such asruntime,reply,workeror effect kinds. Use the kind's name and reservesync:<id>for Policy syncs. Anerrormakes the status script exit 2 and the hourly check notify the operator, so report it only when human action may be needed.
Validate CLI responses before converting them:
/** One message as `hookq pull` and `hookq thread` print it. */
const MessageSchema = z.looseObject({
id: z.string().min(1), conversation: z.string().min(1), thread: z.string().optional(),
sender: z.looseObject({ id: z.string(), bot: z.boolean().default(false) }), mentions_me: z.boolean().default(false),
text: z.string(), at: z.string(), url: z.string().optional(),
})
type QueueMessage = z.infer<typeof MessageSchema>
/** The answer of `hookq pull`, whose items are read one by one, and of `hookq thread`. */
const PageSchema = z.looseObject({ me: z.string(), items: z.array(z.unknown()), next: z.number().int() })
const ThreadPageSchema = z.looseObject({ items: z.array(MessageSchema), next: z.string().optional() })/** The Trigger of one queue message; the agent's own messages produce none. */
export function webhookTrigger(message: QueueMessage, me: string): Trigger | undefined {
if (message.sender.id === me) return undefined
const thread = `${message.conversation}:${message.thread ?? message.id}`
return {
id: key('webhook', message.id),
source: 'webhook',
threads: [{ source: 'webhook', id: thread }],
actor: { source: 'webhook', id: message.sender.id },
human: !message.sender.bot,
...(message.mentions_me ? { addressed: 'mention' as const } : {}),
at: message.at,
text: message.text,
channel: { source: 'webhook', thread: `webhook:${thread}`,
address: { conversation: message.conversation, message: message.id, ...(message.url ? { url: message.url } : {}) } },
...(message.url ? { url: message.url } : {}),
meta: { conversation: message.conversation, messageId: message.id, root: message.thread ?? message.id },
}
}- Stable ids. Derive the Trigger id from the queue id through
key.Engine.receivereturnsduplicatefor stored ids, and duplicate accepts create only one Ticket. Re-reading messages is therefore safe. To treat edits as new events, include an edit version in the id, as GitHub does with commentupdated_at. - Threads. The first Thread is primary and owns the Eval. Additional Threads add Correlation context. The Source chooses the id; its full key is
webhook:<id>. - Sender. Use Source-native ids in
actor. Sethuman: falsefor bots and CI. Listed bots can only become inferred candidates. An empty actor id, as for CI, uses the admission cell for unlisted, unaddressed senders. Setaddressedtomentionordirectonly when the message actually addresses the agent; this gives an allowed person's message explicit strength. Never produce a Trigger for the agent's own message. - Reply destination and metadata.
channelidentifies where replies go;urllinks to the source message.channel.threadgroups messages for ordered delivery: a Ticket's later messages wait behind its earlier ones until an earlier message has failed three times. Other Tickets do not wait. Keep Source-specific fields inmeta, such as the receiving conversation and Thread root. Bothmetaandtextare untrusted data, not authorization.
Agents can read the new kind through orchestrator_read trigger/<id> and thread/<key>. These expose message text, disposition and neighboring Triggers. A new kind does not automatically receive a built-in skill, resources for original service records or pull-request binding.
Text search works without extra code. Implement find(query) when people use Source-specific references such as message ids, URLs or #12. Return the Thread keys and search terms those references identify:
find: query => ({ threads: [], terms: [...new Set(query.match(/\bm[0-9]+\b/g) ?? [])] }),Here a queue message id in the query, such as m42, searches the Triggers for it.
scope({ meta }, config) {
const conversation = config.conversations.find(item => item.id === meta.conversation)
if (!conversation) return { listened: false, reason: 'conversation_not_listed' }
return { listened: true, observed: conversation.listen === 'all', row: conversation.id }
},The Engine calls scope through SourceRegistry for each incoming Trigger. Out-of-scope messages are stored as filtered with their reason and create no Thread. Pass messages to receive rather than silently dropping them in the polling loop, so the panel can explain filtering.
observed: true means the Source receives unaddressed messages in that scope, allowing admission-table decisions for them. row identifies the corresponding admission row.
Revalidation calls scope with the Mandate's stored metadata. Make it depend only on meta and configuration. Resuming requires listened, not observed. Without scope, a kind receives every Trigger it produces but observes none.
channel: {
allowed: (address, config) => config.conversations.some(item => item.id === address.conversation),
send: sendWebhook,
link: address => ({ url: address.url ?? '',
label: { zh: `Webhook 对话 ${address.conversation}`, en: `Webhook conversation ${address.conversation}` } }),
},export async function sendWebhook(host: Pick<SourceContext, 'commands'>, message: OutboundMessage): Promise<'sent' | 'gone'> {
const { conversation, message: to } = message.channel.address
if (!conversation || !to) throw new Error('Missing webhook reply target')
try {
await host.commands.run('hookq', ['reply', '--conversation', conversation, '--to', to, '--key', message.id,
'--text', message.text])
return 'sent'
} catch (error) {
if (error instanceof CliError && error.code === 410) return 'gone'
throw error
}
}- Idempotent sends. Outbox calls
SourceRegistry.send, which invokeschannel.send. Messages wait while the Source plugin is unloaded. A send may fail after the remote service received it, so retries must not duplicate messages. Usemessage.idas an idempotency key:hookqsupports--key; GitHub stores a hidden comment marker; Feishu accepts an idempotency key. - Permanent versus retryable failure. Return
senton success andgoneonly when delivery is permanently impossible, such as the example's 410. A gone message is retained as dropped and never queued again; it appears inGET sourcesand on the Source page. Throw for other failures so Outbox retries after 5 seconds, doubling to 300 seconds.CliError.codeis the code reported by the CLI output. - Allowed destinations.
allowed(address, config)checks only the saved address and current configuration. Put every required fact in the address; for example, Feishu records whether the chat is direct. A destination no longer listened to is dropped, while a disabled Source leaves messages waiting. Withoutallowed, all addresses are permitted while the Source is enabled. - Links.
link(address, links)returns the URL and label the panel displays throughSourceRegistry.link.
threadContext: {
messageId: trigger => String(trigger.meta.messageId ?? ''),
// A queue thread is flat: its root, and the message a reply answers, are on its first page, as on GitHub.
read: async (commands, trigger, _part, cursor) => {
const page = ThreadPageSchema.parse(JSON.parse(await commands.run('hookq', ['thread', '--conversation',
String(trigger.meta.conversation), '--root', String(trigger.meta.root), '--json', ...(cursor ? ['--after', cursor] : [])])))
const messages = page.items.map(item => ({ id: item.id, from: item.sender.id, at: item.at, text: item.text,
...(item.sender.bot ? { bot: true as const } : {}), ...(item.url ? { url: item.url } : {}) }))
return { messages, ...(page.next ? { next: page.next } : {}), body: { messages } }
},
},Proactive assessment needs the conversation around a Trigger. orchestrator_eval reads an initial window; orchestrator_eval_read expands pages, the root or the parent message. Implement the optional threadContext interface to provide these reads:
read(commands, trigger, part, cursor, config)returns aContextPart. ItsmessagesuseContextMessage:{id, from, at, text}plus applicablebot,deleted,reply_to,url,pathandlinefields.nextis omitted on the last page.bodyis the content shown to the assessing Session, here the same messages. Optionalstatuscan describe special results such as Feishu'schat_neighbours.partisthread,rootorparent.messageId(trigger)finds the Trigger's own message among them.images(text)andimage(…)are for a kind whose messages carry images, as Feishu's do; the example leaves them out.
A kind without threadContext gives assessments no Thread context; the Trigger's own text stays readable.
If messages reply to earlier messages, add repliesTo(trigger). It returns the corresponding Trigger id, allowing follow-up notices and worker Inputs to show the replied-to message when stored. For example, if hookq supplies reply_to and translation stores it as meta.replyTo:
repliesTo: trigger => typeof trigger.meta.replyTo === 'string' ? key('webhook', trigger.meta.replyTo) : undefined, policy: {
schema: z.object({ allowed: z.array(z.object({ id: z.string().min(1), name: z.string().trim().max(100).optional() }).strict())
.default([]) }).strict(),
fields: [{ key: 'allowed', type: 'rows', label: { zh: '能发起工作的发送者', en: 'Senders who may originate work' }, columns: [
{ key: 'id', type: 'text', label: { zh: '发送者', en: 'Sender' } },
{ key: 'name', type: 'text', label: { zh: '名字', en: 'Name' }, optional: true }] }],
explicit: { zh: '名单里的人在消息里 @ 智能体。', en: 'Someone on the list mentions the agent in a message.' },
rows: stored => ConfigSchema.parse(stored).conversations.map(conversation => {
const all = conversation.listen === 'all'
return { id: conversation.id, label: conversation.id, note: all ? { zh: '全部消息', en: 'All' } : { zh: '仅 @', en: '@ only' },
cells: { listedUnaddressed: all, unlistedAddressed: true, unlistedUnaddressed: all } }
}),
},schemavalidates the Source Policy, includingallowedsender ids. Id comparison is case-insensitive. Admission tables are validated separately and shared across kinds. This Policy part is saved atpolicy.sources.webhookand contributes to the Policy version.fieldsgenerates the form under Eval → On each Source, beginning withallowed.explicitexplains how to address the agent. Optionalunnamed: {person, bot}supplies display labels for opaque ids such as Feishu'sou_…; this example omits it because queue ids are readable names.rows(config)lists admission scopes and their available cells. Each row id must match therowreturned byscope. Unaddressed-message cells are available only where the Source observes those messages. Kinds withoutrowsuse default cells.- Optional
botslists bots eligible for inferred candidacy, using the same entry shape asallowed. Without it, no bot is admitted. - Optional
sync: {label, ready(part), run(host, part, signal)}refreshes membership at startup and on Sync now. Feishu uses chat membership. Failures appear undersync:<id>and retry after one minute, doubling to one hour until successful.
environment: {
variables: [{ key: 'DSH_SUPERAGENT_WEBHOOK_CONVERSATION', description: {
zh: '接下工单的 webhook 对话;工单不是从这个来源来的时为空',
en: 'The webhook conversation that started the ticket; empty when the ticket did not start on this Source' } }],
values: ({ trigger }) => ({
DSH_SUPERAGENT_WEBHOOK_CONVERSATION: trigger?.source === 'webhook' ? String(trigger.meta.conversation ?? '') : '' }),
},Execution settings show declared variables beside the prepare command even when the Source is disabled. While enabled, values supplies Ticket-specific values using deployment, configuration and the originating Trigger.
The prepare command reads environment variables; the worker reads their values in its instructions. Use the DSH_SUPERAGENT_<KIND>_… prefix, return an empty value for Tickets from another Source, and never include credentials.
export const name = 'dsh-superagent-webhook'
export const inject = ['orchestrator']
export function apply(ctx: Context): void {
ctx.effect(() => ctx.orchestrator.sources.register(webhookKind), 'webhook: source kind')
ctx.effect(() => {
const source = sourceContext(ctx.orchestrator, webhookKind)
const controller = new AbortController()
// DSH ends its process on an unhandled rejection, so a failure that escapes the loop becomes the Source's health.
const running = watch(source, controller.signal)
.catch((error: unknown) => { source.report('webhook', { state: 'error', detail: String(error) }) })
return async () => { controller.abort(); await running }
})
}The plugin injects orchestrator. Its first effect registers the kind and unregisters it on disposal. The second runs the polling loop with a SourceContext. Disposal aborts the loop and awaits completion, including during application stop or restart.
async function watch(ctx: SourceContext, signal: AbortSignal): Promise<void> {
await ctx.ready
// Setup restarts dsh-superagent once it resolves the accounts; until then the Source reads nothing.
if (!ctx.configured()) { ctx.report('webhook', health('paused', 'setupPending')); return }
let failures = 0
while (!signal.aborted) {
const config = ctx.config<WebhookConfig>('webhook')
if (!config) ctx.report('webhook', health('paused', 'sourceOff'))
else {
const at = new Date().toISOString()
try {
const { read, unreadable } = await pollQueue(ctx, signal)
failures = 0
ctx.report('webhook', { state: 'ready', detail: `${read} messages, ${unreadable} unreadable`, at })
} catch (error) {
failures += 1
// One network failure is retried on the next round; three in a row need a person.
if (!signal.aborted && failures >= 3) {
ctx.report('webhook', health('error', 'pollFailures', { count: String(failures), error: String(error) }))
}
}
}
await delay((config?.pollSeconds ?? 30) * 1000, undefined, { signal }).catch(() => {})
}
}
/** Read the queue after the cursor, take in each message, then move the cursor past them. */
export async function pollQueue(ctx: SourceContext, signal: AbortSignal): Promise<{ read: number; unreadable: number }> {
const after = ctx.state.get(KV.webhook.cursor) ?? 0
const output = await ctx.commands.withSignal(signal).run('hookq', ['pull', '--after', String(after), '--json'])
const page = PageSchema.parse(JSON.parse(output))
let unreadable = 0
for (const item of page.items) {
if (signal.aborted) throw new Error('The webhook Source stopped during the read')
const message = MessageSchema.safeParse(item)
if (!message.success) { unreadable++; continue }
const trigger = webhookTrigger(message.data, page.me)
if (!trigger) continue
await ctx.receive(trigger)
const conversation = { label: { zh: '对话', en: 'Conversation' }, value: message.data.conversation }
for (const ref of trigger.threads) ctx.threads.describe(threadKey(ref), { facts: { conversation } })
}
ctx.state.set(KV.webhook.cursor, page.next)
return { read: page.items.length, unreadable }
}-
Setup and the switch. Wait for
ctx.ready, and read nothing whilectx.configured()is false. Readctx.config(kind)on every round: it is undefined while the Source is off. -
Cursors. Store read positions in
ctx.stateusing typed entries inKV(src/kv.ts), such aswebhook: {cursor: record<number>('webhook-cursor')}.tests/store.spec.tsrequires key literals to appear only in that registry. External packages can define keys with exportedrecordandfamily. Settingundefinedremoves a record;ctx.atomicgroups writes in a transaction.Advance a cursor only after every covered Trigger has been stored. An interrupted round leaves the cursor unchanged; stable ids make replay safe. For timestamp cursors,
cursorAt(started)fromsrc/sources/poll.tssubtracts five seconds to allow for late remote writes. -
Thread metadata. After receiving a Trigger, call
ctx.threads.describe(key, {label, url, facts})to populate its display information.ctx.threads.patch(key, meta)delegates tothreads.patchMeta, setting Source-owned keys or removing them when the value isundefined. Both ignore absent Threads, including those of filtered Triggers. -
Per-item errors. Count malformed items and continue rather than failing the entire batch.
validItems(item, list, tally)insrc/infra/commands.tssupports this pattern. For independently polled Threads, usepollThreadsfromsrc/sources/poll.ts; it records each failure aspollError. Itspolledpredicate keeps live subscriptions and a two-week window after the later of the last Trigger and Ticket end. -
Health. Write only declared entries through
ctx.report.health(state, code, values)provides fixed messages such assetupPending,sourceOffandpollFailures. Count consecutive failures before reportingerror. -
Abort. Pass the signal to
ctx.commands.withSignal(signal)and todelay, and check it between items, so a stop or a restart ends the loop at once.
This example does not send Source-originated notices. A Source that needs them calls ctx.notify; Outbox delivers them like other replies.
It also does not bind Threads to Ticket completion. Use ctx.tickets.bindThread and threadClosed only when a Source's Thread lifecycle determines whether work is done, as GitHub pull requests do. The Engine still decides when the Ticket ends.
Build the module as its own entry in tsdown.config.ts:
entry: { index: 'src/index.ts', 'github-watch': 'src/sources/github.ts', 'feishu-watch': 'src/sources/feishu.ts',
'webhook-watch': 'src/sources/webhook.ts' },Then export the built file in package.json by adding "./webhook-watch": "./lib/webhook-watch.js" to exports, and add the plugin to the insert list of the bundle's cordis.patch.yml, disabled, as Feishu is:
- id: dsh-superagent-webhook
name: dsh-superagent/webhook-watch
disabled: trueEnable the plugin in a profile's cordis.patch.yml with id: dsh-superagent-webhook and disabled: false. Build and restart. The new Source appears in Sources settings and under Eval → On each Source.
Add its module and spec to the module and test tables in 007 Architecture. tests/docs.spec.ts checks that both indexes are complete.
Create tests/webhook.spec.ts using the pattern in tests/sources.spec.ts: a real Store in a temporary directory, a SourceRegistry with HealthBoard, an Engine with stand-in ports, and a SourceContext from sourceContextOf.
Mock CLI calls by spying on Commands.run. Also spy on withSignal, because it normally returns a new Commands instance.
function host(overrides: Partial<SourceContextDeps> = {}) {
const store = new Store(join(mkdtempSync(join(tmpdir(), 'webhook-')), 'db.sqlite'))
store.saveSettings(SettingsSchema.parse({
sources: { webhook: { enabled: true, config: { conversations: [{ id: 'ops', listen: 'mentions' }] } } },
policy: { sources: { webhook: { allowed: [{ id: 'alice' }] } } },
}))
// The registry claims the kind's health entries on the board, where the test reads them.
const board = new HealthBoard()
const sources = new SourceRegistry(() => store.settings(), board)
sources.register(webhookKind)
const engine = new Engine({ repo: store, scope: sources, policy: () => settingsPolicy(store.settings(), sources),
assessor: { request: vi.fn() }, executor: { start: vi.fn(), deliver: vi.fn(), stop: vi.fn() },
outbox: { send: vi.fn(), status: () => undefined, withdraw: vi.fn() } })
const commands = new Commands(() => testDeployment(), {}, new AbortController().signal)
const receive = vi.fn((trigger: Trigger) => { const result = engine.receive(trigger); engine.evaluateDue(); return result })
// The Source's own notices go to the outbox, which keeps their receipts and sends nothing here.
const deps: SourceContextDeps = { store, engine, outbox: { send: message => { store.receipts.add(message) } }, commands, sources,
deployment: testDeployment(), ready: Promise.resolve(), configured: () => true, receive, ...overrides }
return { ...deps, store, engine, commands, receive, health: board.all, ctx: sourceContextOf(deps, webhookKind) }
}
/** The queue as `hookq` answers it. */
function queue(h: ReturnType<typeof host>, answer: () => unknown) {
vi.spyOn(h.commands, 'withSignal').mockReturnValue(h.commands)
return vi.spyOn(h.commands, 'run').mockImplementation(async () => JSON.stringify(answer()))
}
it('takes in what the queue holds after the cursor, once, and moves the cursor after it', async () => {
const h = host()
queue(h, () => ({ me: 'agent', items: [message('m1'), { id: 'broken' }, message('m2', { conversation: 'elsewhere' })], next: 3 }))
expect(await pollQueue(h.ctx, new AbortController().signal)).toEqual({ read: 3, unreadable: 1 })
expect(await h.receive.mock.results[0]!.value).toBe('stored')
expect(await h.receive.mock.results[1]!.value).toBe('filtered')
expect(h.store.get(KV.webhook.cursor)).toBe(3)
// An allowed sender who mentions the agent makes an explicit request.
expect(h.engine.candidate(key('webhook', 'm1'))?.strength).toBe('explicit')
await pollQueue(h.ctx, new AbortController().signal)
expect(await h.receive.mock.results[2]!.value).toBe('duplicate')
h.store.close()
})message(id, fields) builds a queue item. Cover at least:
- the translation: a stable id, the Thread, the actor,
humanandaddressed, and no Trigger for the agent's own message; - a round: Triggers stored once, one outside the scope
filtered, the cursor moved after them, an allowed sender's mention evaluated asexplicit; - a round cut short, which keeps its cursor;
send:sent,gonefor a 410 only, and a throw for any other failure, with the CLI's error built bycommandError;- the Thread context: the messages of a page and the cursor of the next, as
tests/registry.spec.tsreads a kind's Threads throughSourceRegistry.threadContext; - the plugin:
applyregisters the kind and reportssetupPendingwhile setup waits, its disposers unregister the kind, and a failure that escapes the loop becomes the Source's health instead of an unhandled rejection; - the Source's part of the Policy and its admission rows, as
tests/registry.spec.tsandtests/policy.spec.tscheck the Feishu Source's.
- Map Triggers, Threads, actors, Channel addresses and receiving scopes.
- Write the kind:
schema,fields,health, andfactsif setup derives any. - Translate events into Triggers with stable ids, and drop the agent's own.
- Decide what the Source receives in
scope, frommetaalone. - Give it a Channel whose
sendposts each message once and answersgoneonly when the conversation can never receive it. - Give assessments its Thread context, if it can read a Thread.
- Give it a part of the Policy with
allowedand the admission rows. - Register its execution environment variables.
- Write the plugin entry and a loop that waits for setup, follows the switch, moves cursors after
receive, keeps errors per item, reports health and stops on abort; name the cursors inKV. - Add the build entry, the export and the
cordis.patch.ymlinsert, and list the module and its spec in the tables of 007. - Test it as
tests/sources.spec.tstests the other Sources.