How We Built Charlie, Part 3: Turning Webhooks into Work
How Charlie verifies, normalizes, enriches, filters, and routes provider notifications into trustworthy engineering work.
How Charlie prevents duplicate or orphaned work by choosing the right ownership outcome for each Signal.
How We Built Charlie continues from Chapter 3: an enriched Signal now needs an accountable ownership outcome.
Three comments land on a release issue within a minute. The first asks Charlie to investigate a rollback that left one service on the wrong revision. The second adds a useful constraint: the mismatch only appears in one repository. The third is an automated status update from a deployment bot.
There should be one investigation. The explicit request should establish an owner. The clarification should join that active work. The automated status should remain available as source context when useful, but it should not create another Task merely because it arrived nearby.
This is the routing problem: choose the appropriate ownership outcome for a Signal. It may start a new Task, append to an existing Task’s mailbox, activate one or more repository-defined daemon roles, or produce no work. The decision assigns responsibility. A bad outcome can create competing actors, hide a correction from the active owner, wake an unrelated specialist, or spend compute on noise.
Charlie treats routing as a policy decision over durable identities, current lifecycle state, repository context, and activation rules. The contracts and traces below are simplified. They describe the important boundaries without exposing private identifiers, internal topology, or every field carried through the implementation.

Routing assigns an ownership class. Normal routing selects one fallback outcome; a successful daemon-policy class may activate more than one scoped role.
By the time routing begins, Charlie has already authenticated and normalized the provider event. The Signal carries enough information to recover the source material and reason about the collaboration surface. It has its own ID and request correlation, but those values alone do not say which work it belongs to.
One important field is charlieUri: a canonical, provider-aware address for the resource or thread represented by the Signal. A GitHub issue comment, Linear comment, or Slack thread can have very different provider payloads while still yielding a structured address that routing code can inspect consistently.
A simplified Signal fragment looks like this:
{
id: "signal_example_followup",
requestId: "request_example_release",
charlieUri:
"charlie://customer_example/v1/github/owner/example/repo/release/issue/84/comment/203",
charlieUriData: {
kind: "github/issue-comment",
provider: "github",
owner: "example",
repo: "release",
issueNumber: 84,
commentId: 203
},
sender: { id: "user_example", isBot: false },
eventType: "issue_comment",
eventAction: "created"
}
The full Signal also carries customer and environment scope plus optional event metadata. The original source payload remains recoverable from a deterministic storage path derived from the environment and Signal ID. The Task receives a smaller fragment so later execution can retain source identity without copying every ingress field into the lifecycle record.
Several identities need to remain distinct:
| Identity | Routing use |
|---|---|
| Provider delivery | Trace receipt and recognize provider redelivery |
| Signal ID | Identify one enriched routing input |
charlieUri | Address the collaboration resource or thread |
| Request correlation | Connect processing and telemetry for one request path |
| Task ID | Identify the durable owner of an engineering objective |
| Coordination key | Converge eligible daemon activations on one active owner |
| Effect idempotency key | Make one scheduler operation converge when the same routing attempt repeats |
Two messages in one Slack thread are separate Signals. They may still belong to one active Task. A redelivered provider callback may repeat one Signal’s logical routing attempt and should not create a second scheduler effect. A second explicit request in the same issue may deserve a new Task if it establishes a different objective. Thread identity is evidence, not ownership by itself.
The output of routing is easier to review when each route has a clear ownership meaning.
| Route | Ownership | Example |
|---|---|---|
| New Task | Establish a new durable owner for an objective | “Find why the release deployed mixed revisions.” |
| Existing Task mailbox | Add relevant input to work that still owns the objective | “The mismatch only occurs in the API repo.” |
| Daemon routes | Activate scoped repo roles under policy | A matching event reaches its scoped review and release roles |
| No-op / drop | Decline to create work | An unknown bot update, invalid event, own event, or duplicate |
These outcomes carry different responsibility. Creating a Task adds another lifecycle and another potential actor. A mailbox append preserves the current owner. Daemon routing applies predeclared role and activation contracts, and policy can select several scoped roles for one Signal. A no-op records that the right outcome is no new work.
Normal routing chooses at most one fallback: an existing Task, a new Task, or no work. The successful daemon-route class is the narrower exception because one Signal may legitimately match more than one independent scoped role.
The release issue shows why the distinction matters. If all three comments become Tasks, Charlie may produce competing explanations or duplicate patches. If the clarification is dropped, the active investigation can miss the fastest path to the fault. If the deployment bot update wakes a general-purpose agent, proximity has been mistaken for intent.
Routing compares the Signal with active non-daemon Tasks in the customer scope and uses the available context to make the ownership decision. It still has to determine whether that Task can accept more input.
For an active target, the orchestrator renders execution content and asks the scheduler to append a durable mailbox message. The scheduler operation includes an idempotency key so a repeated routing attempt converges at that append boundary.
sendNewMessage({
customerId,
taskIds: [candidateTaskId],
content: renderedSignal,
idempotencyKey: mailboxKey,
});
This example omits retry configuration and event metadata. The important point is that routing does not mutate an executor transcript directly. It writes to the scheduler-owned mailbox of a durable Task. The executor can retrieve the message at a later checkpoint, as described in Chapter 2.
There is a race between choosing the candidate and appending the message. The Task may finish in that interval. Charlie handles that race carefully because a failed append does not automatically authorize replacement work.
That last case prevents a subtle failure. A transient lookup disagreement must not become two owners. “Mailbox append returned not found” is an observation about one operation, not proof that the previous owner is gone.

Signals can arrive through several surfaces while one Task remains the owner. A new owner is created only when policy calls for new work or the previous target is confirmed terminal.
Charlie can receive related input through GitHub, Linear, and Slack. The product experience should still feel like one investigation when the objective is one investigation.
Suppose the release request begins in GitHub. A program manager adds the affected customer tier in Linear. An engineer pastes a reproduction command into the connected Slack thread. If routing has evidence that all three inputs change the same active objective, each can become a mailbox message for the same Task.
The Task supplies continuity across surfaces. The providers still retain separate identities, permissions, source artifacts, and reply locations. Routing should not flatten them into one synthetic conversation. It should preserve where each fact came from while choosing one owner.
The reverse also matters. Two comments in one GitHub issue can request separate work. “Investigate the mixed revision” and “replace the deployment platform next quarter” share a thread but not necessarily an objective, urgency, or completion condition. A durable owner should be narrow enough that its result can answer what it was asked to do.
This is why charlieUri is necessary but insufficient. It gives routing a stable address for the source. Semantic classification, explicit activation, repository context, active ownership, and policy decide what that source means for work.
Many engineering requests need a repository before execution can begin. Routing may infer it from the Signal’s canonical resource, provider metadata, referenced artifacts, or configured customer context. The selected repository then participates in agent and daemon resolution.
A normal Charlie Task uses the agent label configured for that mode. A daemon route also resolves the repository-defined daemon file and its agent label. These values are part of the schedule request because they determine which bounded role will execute the Task.
Conceptually:
const repo = inferRepository(signal, routingContext);
const target = resolveAgentOrDaemon(policy, repo);
scheduleTask({
customerId,
content: renderForExecution(signal),
repo,
agentLabel: target.agentLabel,
daemonFile: target.daemonFile,
signal: toSignalFragment(signal),
idempotencyKey: scheduleKey,
});
Repository inference is not an excuse to guess silently. A route can decline work when required context is unavailable or ambiguous. Starting in the wrong repository creates a convincingly active Task with the wrong authority, which is worse than an explicit no-op or request for clarification.
Agent labels are also routing constraints rather than decorative metadata. They select an execution contract. A general engineering agent and a release-triage daemon may use overlapping tools, but they do not own the same class of events.
A daemon in Charlie is a persistent role definition with bounded activations. It is not a process that runs forever. When a matching Signal arrives, routing can schedule a new daemon Task or append the activation to the mailbox of an existing coordination owner.
The scheduler’s coordination operation makes that decision atomically within its own boundary:
scheduleOrAppendByCoordinationKey({
customerId,
coordination: {
key: 'repo-defined-role-and-scope',
activation: renderedSignal,
},
scheduleRequest,
idempotencyKey: coordinationKey,
});
If an active owner exists for the coordination key, the activation is appended and the existing Task remains responsible. If none exists, the scheduler creates a new owner. If the previous owner has become terminal, a later activation can establish another bounded Task.
This supports product experiences such as a repository-defined review daemon receiving several relevant CI or review events without starting one Task per callback. It also keeps unrelated events out. A daemon should wake because its declared activation policy matches, not because a webhook happened to mention a familiar repository.
Coordination keys and scheduler idempotency keys solve different problems. The coordination key asks, “which active Task owns this class of activation?” The operation idempotency key asks, “has this exact scheduling or append effect already been applied?” Keeping them separate prevents one source redelivery from becoming another activation while still allowing later, distinct activations to reach the same owner.
Routing deals with two kinds of duplication.
Delivery duplication occurs when a provider, queue, or caller repeats the same logical attempt. Stable operation keys can make the scheduler append or schedule converge. This is a mechanical property at a defined boundary.
Semantic duplication occurs when distinct Signals ask for the same work. Three engineers may mention Charlie separately after seeing the same release failure. Their deliveries, Signal IDs, and message text differ. An idempotency key cannot decide that they mean one investigation.
Semantic deduplication depends on ownership policy: canonical resource identity, active Tasks, objective similarity, explicit references, sender intent, daemon coordination, and current lifecycle. It is probabilistic or policy-driven where meaning is involved, so the system should avoid claiming more certainty than it has.
The strongest accurate statement is narrower:

Identity makes the source addressable, policy assigns meaning, and operation keys make one scheduler effect converge. No one key provides global deduplication.
Systems that can act need a disciplined way not to act. The no-op route handles events that are unsupported, non-actionable, irrelevant, self-generated, duplicate at an already-protected boundary, or disallowed by policy.
For the release issue, the deployment bot’s status comment might still be recoverable in the provider thread. It does not need a Task simply to preserve visibility. If a human later asks Charlie to interpret that status, the explicit request can create or update owned work with the bot comment as context.
Treating no-op as a first-class outcome has operational benefits:
Dropping work should still be explainable through internal decision evidence. “No Task was created” is useful only when operators can distinguish policy, quota, unsupported input, and retry exhaustion where appropriate.
Routing policy changes can alter ownership at scale. A small classifier adjustment may turn thousands of harmless status events into Tasks, or divert relevant follow-ups away from active work. We prefer to observe a proposed decision before allowing it to control effects.
In a shadow phase, a new classifier or policy evaluates Signals and records what it would have chosen while the established policy remains authoritative. The comparison can focus on decision classes and high-value slices:
| Comparison | Question |
|---|---|
| New Task vs mailbox | Would the candidate create duplicate ownership or hide a separate request? |
| Daemon vs normal Task | Does the specialist’s activation scope match the event and repository? |
| Work vs no-op | Is the candidate waking on automated noise or dropping explicit requests? |
| Same route, different repo | Did repository inference change the actual execution authority? |
| Same route, new target | Is the candidate attaching input to the correct active owner? |
Rollout can then proceed by provider, customer cohort, event type, repository class, or confidence band. The exact rollout controls are implementation details; the principle is stable. Ownership policy deserves staged evidence because mistakes create durable work.
Shadow results are not ground truth. Human labels can be incomplete, later context can change what the right decision looks like, and a classifier can agree with existing policy while both are wrong. Useful evaluation combines sampled review, downstream outcomes, duplicate-owner signals, mailbox correction rates, and explicit operator feedback.
Return to the three comments. The explicit request creates a Task after repository and agent resolution. The clarification becomes a mailbox append because it changes the same active objective. The automated status becomes a no-op because it does not independently ask for work. If repository policy matches one or more daemon roles, each selected role can wake or rejoin its bounded coordination owner.
Each route makes a different promise about responsibility. The scheduler’s effect keys protect repeat attempts at their boundaries. Active-state checks and terminal reconciliation prevent an append race from casually creating a second owner. Coordination keys let distinct daemon activations share one active role. None of those mechanisms can replace semantic policy, and none makes the whole system exactly once.
Routing turns a Signal into accountable work, a relevant continuation, scoped specialist activations, or an explainable decision to do nothing. That clarity lets every later phase know which Task or role owns the next action.
Previous: How We Built Charlie, Part 3: Turning Webhooks into Work. Next in the series: How We Built Charlie, Part 5: Scheduling Durable Work (forthcoming). Browse the full How We Built Charlie series.