Skip to content

Commit 8e02e56

Browse files
khaliqgantclaude
andcommitted
feat(agents): refactor handlers to spec/proactive-runtime API
Each agent now matches the contract from cloud/docs/proactive-runtime/spec.md — one `agent({ workspace, schedule, watch, inbox, onEvent })` call per file, single handler covering all triggers, progressive-disclosure events. agents/shared/sdk.ts local mirror of @agent-relay/agent — types + no-op shim. One-line swap when the real package ships. agents/weekly-digest/agent.ts fleshed out end-to-end with realistic source choices (Tavily, Reddit JSON, GH upsert) marked TODO where the runtime still needs to land. Does the dedupe-and-cluster work itself; only one rolling issue per week. agents/{notion-to-blog,sunday-ping,pr-reviewer,manual-chatbot}/agent.ts same shape, scaffolded against the spec with restraint paths logged when they fire (skip-with-reason vs act). Build fix: sitemap.ts and robots.ts need `export const dynamic = "force-static"` to play with `output: "export"`. Plus the SEO scaffolding (lib/seo.ts, app/sitemap.ts, app/robots.ts, metadata on each page, llms.txt) for the OG/structured-data layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 303c3ee commit 8e02e56

23 files changed

Lines changed: 1380 additions & 314 deletions

File tree

agents/README.md

Lines changed: 62 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,76 @@
11
# /agents
22

3-
The proactive agents that run this site. Each subdirectory is one agent. Each
4-
agent has a single entry point — `handler.ts` — that takes a typed event and
5-
returns nothing (side effects only).
3+
The proactive agents that run this site. Each agent is a single
4+
`agent({ ... })` call against the proactive-runtime SDK contract.
65

7-
## Why these live in this repo
6+
## The contract
87

9-
This site argues that a proactive agent is small code wrapped around the right
10-
runtime. Co-locating the agents with the site they operate keeps the demo
11-
honest and the deploy simple.
8+
Source: [`AgentWorkforce/cloud · spec/proactive-runtime · docs/proactive-runtime/spec.md`](https://github.com/AgentWorkforce/cloud/blob/spec/proactive-runtime/docs/proactive-runtime/spec.md).
129

13-
## The roster
14-
15-
| Directory | Trigger | What it does |
16-
|--------------------|---------|------------------------------------------------------------------------------|
17-
| `notion-to-blog` | change | Watches a Notion database. When a page flips to `ready`, converts to MDX and opens a PR. |
18-
| `weekly-digest` | time | Weekly. Searches the web + Reddit for "proactive agents" mentions. Files one rolling GitHub issue, deduped, grouped by topic. |
19-
| `sunday-ping` | time | Sundays at 09:00 local. Reads the latest weekly digest and pings me on Slack with a draft outline for the next post. |
20-
| `pr-reviewer` | change | Comments on PRs to this repo with deploy preview, dead-link check, copy-edit notes. |
21-
| `manual-chatbot` | message | Answers Slack DMs / emails grounded in the published essays. |
10+
The shape is:
2211

23-
## Shape
12+
```ts
13+
import { agent } from "@agent-relay/agent";
2414

15+
agent({
16+
workspace: "support",
17+
schedule: "*/5 * * * *",
18+
watch: ["/zendesk/tickets/**"],
19+
inbox: ["@self"],
20+
onEvent: async (ctx, event) => {
21+
// event.type is "cron.tick" | "relayfile.changed" | "relaycast.message"
22+
},
23+
});
2524
```
26-
agents/
27-
shared/
28-
log.ts # writeLogEntry({ agent, action, summary, ... }) → appends to content/agent-log.json
29-
types.ts # AgentEntry, Trigger, Outcome (re-exports from lib/agent-log.ts)
30-
notion-to-blog/
31-
handler.ts # export async function handler(event: NotionPageEvent)
32-
weekly-digest/
33-
handler.ts # export async function handler() — invoked by relaycron
34-
sunday-ping/
35-
handler.ts # export async function handler()
36-
pr-reviewer/
37-
handler.ts # export async function handler(event: PullRequestEvent)
38-
manual-chatbot/
39-
handler.ts # export async function handler(event: InboxMessage)
25+
26+
One handler. Three triggers. One workspace. The whole API for the 90% case.
27+
28+
## Status: spec-ahead
29+
30+
`@agent-relay/agent` is not yet published. Until it is, the agents import
31+
from `agents/shared/sdk.ts` — a local mirror of the spec types plus a no-op
32+
`agent()` shim. When the package ships, the shim file gets one diff:
33+
34+
```diff
35+
- export { agent, type AgentDefinition } from "./local-shim";
36+
+ export { agent, type AgentDefinition } from "@agent-relay/agent";
4037
```
4138

42-
## Wiring
39+
Every agent file then runs unchanged.
40+
41+
## The roster
42+
43+
| File | Trigger | What it does |
44+
|-----------------------------------|---------|---------------------------------------------------------------|
45+
| `notion-to-blog/agent.ts` | change | Watch Notion drafts DB; on `status=ready`, MDX → PR. |
46+
| `weekly-digest/agent.ts` | time | Saturday 09:00 UTC. Web + Reddit → one rolling GitHub issue. |
47+
| `sunday-ping/agent.ts` | time | Sunday 09:00 ET. Reads digest, drafts outline, Slack DM. |
48+
| `pr-reviewer/agent.ts` | change | Watch repo PRs; deploy preview, dead-link, copy-edit notes. |
49+
| `manual-chatbot/agent.ts` | message | DMs + `#manual`. RAG over published essays. Refuses by default. |
50+
51+
## Side effects
52+
53+
Every agent calls `writeLogEntry(...)` from `shared/log.ts` so the public
54+
[/agent page](../app/agent/page.tsx) stays current. That page is the receipts.
55+
56+
## Local dev
57+
58+
```bash
59+
# Type-check the agents alongside the rest of the site
60+
npx tsc --noEmit
61+
```
4362

44-
Each handler is invoked by the proactive runtime (`relaycron` for time,
45-
`relayfile` for change, `relaycast` for message). The wiring lives in the
46-
runtime config, not in the handler — so you can read each handler in isolation
47-
and see the full behaviour.
63+
To exercise an agent's behaviour without the runtime, call its `onEvent`
64+
directly from a test with a mock `Context` and `AgentEvent`. The shim's
65+
`agent()` registers the definition and returns; it does not dispatch events.
4866

49-
Every handler ends by calling `writeLogEntry(...)` so the public `/agent` page
50-
on the site stays current. That page is the receipts.
67+
## Wiring (when the runtime ships)
5168

52-
## Status
69+
1. `relay login`
70+
2. `relay workspaces create proactive-agents`
71+
3. `relay providers connect github notion slack reddit tavily`
72+
4. `relay deploy agents/weekly-digest/agent.ts` (and similar for the others)
5373

54-
Skeletons. Each handler currently logs to the activity feed and returns;
55-
external integrations (Notion, GitHub, Slack, Reddit, web search) are stubbed.
56-
Wire them in PRs &mdash; the PR reviewer agent will check your work.
74+
The runtime reads the `agent({...})` definition, registers schedules with
75+
relaycron, watch globs with relayfile, channel subscriptions with relaycast,
76+
and starts dispatching events to `onEvent`.

agents/manual-chatbot/agent.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @manual chatbot — MESSAGE trigger.
3+
*
4+
* Listens to DMs and the #manual channel. Answers questions about proactive
5+
* agents grounded in the published essays (RAG over content/posts/*.mdx).
6+
*
7+
* Restraint matters here more than anywhere else. False answers cost trust
8+
* faster than honest "don't knows". The agent refuses (politely, logged) when:
9+
* - the question isn't on-topic
10+
* - the corpus has no chunk above the relevance threshold
11+
*/
12+
import { agent, type Context, type AgentEvent } from "../shared/sdk";
13+
import { writeLogEntry } from "../shared/log";
14+
15+
const RELEVANCE_THRESHOLD = 0.7;
16+
17+
type MessageExpansion = {
18+
data: {
19+
text: string;
20+
threadId?: string;
21+
channel: string;
22+
user: { id: string; displayName?: string };
23+
};
24+
};
25+
26+
export default agent({
27+
workspace: "proactive-agents",
28+
name: "manual-chatbot",
29+
inbox: ["@self", "#manual"],
30+
31+
async onEvent(ctx: Context, event: AgentEvent) {
32+
if (event.type !== "relaycast.message") return;
33+
34+
const msg = (await event.expand("full")) as MessageExpansion;
35+
const question = msg.data.text;
36+
37+
// 1. Cheap intent classification: is this about proactive agents?
38+
// Use a small Claude Haiku call with a tight rubric.
39+
// TODO: implement isOnTopic(question)
40+
const onTopic = true;
41+
42+
if (!onTopic) {
43+
await polite("Sorry — I only answer questions about proactive agents. Try reframing?");
44+
await writeLogEntry({
45+
agent: "manual-chatbot",
46+
trigger: "message",
47+
action: "Refused — off topic",
48+
summary: `${msg.data.user.displayName ?? msg.data.user.id} asked "${preview(question)}". Off topic; refused.`,
49+
outcome: "skipped",
50+
skippedReason: "off-topic",
51+
});
52+
return;
53+
}
54+
55+
// 2. Retrieve top-K chunks from the published corpus.
56+
// The corpus is content/posts/*.mdx, indexed at deploy time into a
57+
// small embeddings file at /_internal/manual-chatbot/index.json.
58+
// TODO: implement retrieveCorpus(question, topK)
59+
const chunks: { slug: string; title: string; text: string; score: number }[] = [];
60+
61+
if (chunks.length === 0 || chunks[0].score < RELEVANCE_THRESHOLD) {
62+
await polite(
63+
`I couldn't find a confident answer in the published essays. Either the topic isn't covered yet, or the question phrasing doesn't match what's there. Try the [essays index](https://proactiveagents.dev/posts) directly?`,
64+
);
65+
await writeLogEntry({
66+
agent: "manual-chatbot",
67+
trigger: "message",
68+
action: "Refused — low confidence",
69+
summary: `${msg.data.user.displayName ?? msg.data.user.id} asked "${preview(question)}". Top chunk score ${chunks[0]?.score ?? 0} < threshold ${RELEVANCE_THRESHOLD}. Refused.`,
70+
outcome: "skipped",
71+
skippedReason: "low confidence",
72+
});
73+
return;
74+
}
75+
76+
// 3. Draft an answer with inline citations.
77+
// TODO: implement draftAnswer(question, chunks) — Claude Sonnet,
78+
// instructed to cite as [link text](/posts/<slug>) inline.
79+
const answer = "TODO";
80+
81+
await ctx.messages.reply(msg.data.threadId ?? msg.data.channel, answer);
82+
83+
await writeLogEntry({
84+
agent: "manual-chatbot",
85+
trigger: "message",
86+
action: `Answered ${msg.data.channel}`,
87+
summary: `${msg.data.user.displayName ?? msg.data.user.id} asked "${preview(question)}". Answered with ${chunks.length} citation(s).`,
88+
outcome: "success",
89+
links: chunks.slice(0, 3).map((c) => ({ label: c.title, url: `/posts/${c.slug}` })),
90+
});
91+
92+
async function polite(text: string) {
93+
await ctx.messages.reply(msg.data.threadId ?? msg.data.channel, text);
94+
}
95+
},
96+
});
97+
98+
function preview(s: string): string {
99+
return s.length > 80 ? s.slice(0, 80) + "…" : s;
100+
}

agents/manual-chatbot/handler.ts

Lines changed: 0 additions & 51 deletions
This file was deleted.

agents/notion-to-blog/agent.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Notion → blog agent — CHANGE trigger.
3+
*
4+
* Watches the Notion "Drafts" database (path mapped through relayfile-notion).
5+
* When a page flips to status `ready`, converts blocks to MDX and opens a PR.
6+
*
7+
* Restraint: most page changes are NOT "ready" — they're typo fixes mid-draft.
8+
* The agent skips with a logged reason rather than firing on every keystroke.
9+
*/
10+
import { agent, type Context, type AgentEvent } from "../shared/sdk";
11+
import { writeLogEntry } from "../shared/log";
12+
13+
// One Notion DB id per workspace. Set via `relay providers connect notion`
14+
// during workspace setup; the relayfile-notion adapter mounts the DB at
15+
// /notion/databases/<dbId>/pages/* and emits a relayfile.changed event for
16+
// each page write.
17+
const DRAFTS_DB_PATH = "/notion/databases/drafts/pages/**";
18+
19+
type NotionExpansion = {
20+
data: {
21+
id: string;
22+
url: string;
23+
properties: {
24+
Title: { title: { plain_text: string }[] };
25+
Slug: { rich_text: { plain_text: string }[] };
26+
Status: { select: { name: "draft" | "editing" | "ready" | "published" } };
27+
};
28+
blocks: unknown[]; // Notion block list
29+
};
30+
};
31+
32+
export default agent({
33+
workspace: "proactive-agents",
34+
name: "notion-to-blog",
35+
watch: DRAFTS_DB_PATH,
36+
37+
async onEvent(ctx: Context, event: AgentEvent) {
38+
if (event.type !== "relayfile.changed") return;
39+
40+
// Cheap routing decision off the summary — no expand needed yet.
41+
const status = event.summary.status;
42+
const title = event.summary.title ?? "(untitled)";
43+
44+
if (status !== "ready") {
45+
await writeLogEntry({
46+
agent: "notion-to-blog",
47+
trigger: "change",
48+
action: `Skipped — page still ${status ?? "unset"}`,
49+
summary: `Page "${title}" changed but status is "${status ?? "unset"}", not "ready". Nothing published.`,
50+
outcome: "skipped",
51+
skippedReason: `status != ready`,
52+
links: [{ label: "Notion page", url: event.resource.path }],
53+
});
54+
return;
55+
}
56+
57+
// Now we commit to the work — pull the full page including blocks.
58+
const full = (await event.expand("full")) as NotionExpansion;
59+
const slug = full.data.properties.Slug.rich_text[0]?.plain_text ?? slugify(title);
60+
61+
// TODO: blocks → MDX. Handle our Scene / Sidenote / Callout components by
62+
// recognising specific Notion callout/toggle conventions.
63+
// const mdx = blocksToMdx(full.data.blocks, frontmatterFrom(full));
64+
65+
// TODO: open PR via Octokit. Idempotent on (workspace, page.id, page.last_edited):
66+
// if a PR for this page+revision already exists, no-op.
67+
// const prUrl = await ctx.once(
68+
// `notion-pr:${full.data.id}:${event.digest}`,
69+
// () => openPr({ path: `content/posts/${slug}.mdx`, body: mdx, title: `Publish: ${title}` }),
70+
// );
71+
72+
await writeLogEntry({
73+
agent: "notion-to-blog",
74+
trigger: "change",
75+
action: "Published essay",
76+
summary: `New page "${title}" tagged ready. Converted to MDX, opened PR.`,
77+
outcome: "success",
78+
links: [
79+
{ label: "Notion source", url: full.data.url },
80+
{ label: "Will live at", url: `/posts/${slug}` },
81+
],
82+
});
83+
},
84+
});
85+
86+
function slugify(s: string): string {
87+
return s
88+
.toLowerCase()
89+
.replace(/[^a-z0-9]+/g, "-")
90+
.replace(/^-+|-+$/g, "")
91+
.slice(0, 80);
92+
}

0 commit comments

Comments
 (0)