---
title: "Normalize the Request"
description: "Validate incoming GitHub issue data, normalize missing bodies, and attach one stable source issue to the durable eve session after the channel's trust checks pass."
canonical_url: "https://vercel.com/academy/creating-a-software-factory/normalize-the-request"
md_url: "https://vercel.com/academy/creating-a-software-factory/normalize-the-request.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-08-29T01:48:00.550Z"
content_type: "lesson"
course: "creating-a-software-factory"
course_title: "Creating a Software Factory"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Normalize the Request

# Normalize the request

GitHub arrives carrying a suitcase of nested fields. The factory needs four of them.

```ts
{
  body: string,
  number: number,
  title: string,
  url: string,
}
```

Normalization creates a stable boundary between somebody else's webhook shape and every decision we make afterward.

## Shrink the Webhook at the Door

Convert trusted GitHub issue events into validated source issues for durable work orders.

## Hands-on Exercise 2.1

Create `agent/lib/intake.ts`. Validate the raw fields we consume and accept GitHub's two versions of an empty body, `null` and missing:

```ts title="agent/lib/intake.ts"
import type { GitHubIssueEvent } from "eve/channels/github";
import { z } from "zod";

const rawIssueSchema = z.object({
  body: z.string().nullable().optional(),
  html_url: z.url(),
  title: z.string().min(1),
});

export function normalizeIssue(issue: GitHubIssueEvent) {
  const raw = rawIssueSchema.parse(issue.raw);

  return {
    body: raw.body ?? "",
    number: issue.issueNumber,
    title: raw.title,
    url: raw.html_url,
  };
}
```

The nullish coalescing operator preserves a real body and turns either empty representation into `""`. Downstream tools now receive one shape.

Create `agent/lib/intake.test.ts` with a labeled issue event. Assert the exact normalized object, including the issue number supplied by eve rather than the raw payload.

Now open `agent/channels/github.ts`. Keep its existing checks for the factory label, bot senders, and trusted maintainer roles. After those checks pass, normalize the issue and place it in the returned context:

```ts title="agent/channels/github.ts"
const sourceIssue = normalizeIssue(issue);

return {
  auth: defaultGitHubAuth(ctx),
  context: [
    intakeTask,
    `Create the work order from this normalized source issue:\n${JSON.stringify(sourceIssue)}`,
  ],
};
```

This order matters. Invalid or untrusted events return `null` before they can start durable work or spend model tokens.

## Try It

Run the focused test, then inspect the compiled channel:

```bash
pnpm test agent/lib/intake.test.ts
pnpm typecheck
pnpm exec eve info
```

The focused suite reports one passing test. The application still has two root tools because normalization is ordinary TypeScript, not a model-facing capability.

\*\*Note: Send an empty issue body\*\*

Add a second test with `body: null`. The normalized result should contain `body: ""`. The request will later become a clarification outcome instead of crashing intake.

\*\*Warning: Zod rejects a missing body\*\*

Keep both `.nullable()` and `.optional()`. GitHub may send either representation.

\*\*Warning: Untrusted events start sessions\*\*

Call `normalizeIssue` only after the channel's label, sender, and permission checks return successfully.

## Commit

```bash
git add agent/lib/intake.ts agent/lib/intake.test.ts agent/channels/github.ts
git commit -m "feat(factory): normalize trusted issues"
```

## Done-When

- [ ] Intake returns one stable source shape
- [ ] Missing and null bodies become empty strings
- [ ] Invalid URLs fail before entering a work order
- [ ] Untrusted events return before normalization
- [ ] The intake test and typecheck pass

The webhook is now somebody else's shape at the boundary and our stable contract everywhere else.

## Solution

The exercise shows the complete `agent/lib/intake.ts` and channel integration. The finished boundary returns only `body`, `number`, `title`, and `url`, with an absent or `null` body normalized to an empty string.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
