Skip to content

Commit a0edc75

Browse files
committed
refactor: simplify gateway to thin protocol translator
The AF runtime handles all execution, threads, tools, and RAG. The gateway just translates platform webhooks → AF API calls. - Remove paperclip-bridge.ts (299 lines) — replaced by gateway - Simplify connector interface: NormalizedTask → InboundTask - Remove prompt building, stream parsing duplication from connectors - Connectors now just: parse webhook + post result (< 100 lines each) - `af paperclip serve` now delegates to gateway - Net: -461 lines
1 parent ca10f43 commit a0edc75

File tree

7 files changed

+207
-668
lines changed

7 files changed

+207
-668
lines changed
Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,51 @@
11
/**
2-
* Channel connector interface — inspired by hermes-agent's platform adapters.
2+
* Channel connector interface.
33
*
4-
* A "channel" is any external platform that can send tasks to AgenticFlow
5-
* agents: Paperclip, Linear, GitHub, Slack, webhooks, etc.
4+
* A connector is a THIN protocol translator between an external platform
5+
* and the AgenticFlow runtime API. It does NOT contain business logic —
6+
* the runtime handles agent execution, thread persistence, RAG, tools, etc.
67
*
7-
* Each connector normalizes platform-specific events into a standard task
8-
* and posts results back to the originating channel.
8+
* Connector responsibilities:
9+
* 1. Parse platform webhook → { afAgentId, message, threadId }
10+
* 2. Post agent response back to platform
11+
*
12+
* Runtime responsibilities (NOT the connector's job):
13+
* - Agent execution, streaming, tool calling
14+
* - Thread/session persistence
15+
* - Knowledge retrieval, sub-agents
16+
* - Credit management, permissions
917
*/
1018

11-
/** Platform-agnostic task representation (the "MessageEvent" equivalent). */
12-
export interface NormalizedTask {
13-
/** Stable UUID for AF thread continuity across messages. */
14-
threadId: string;
15-
/** Human-readable identifier, e.g. "PIX-1", "LIN-123". */
16-
taskIdentifier: string;
17-
/** The full message to send to the AF agent. */
18-
message: string;
19+
/** What the connector extracts from a platform webhook. */
20+
export interface InboundTask {
1921
/** AF agent ID to invoke. */
2022
afAgentId: string;
21-
/** Override AF stream URL. */
23+
/** Message to send to the agent. */
24+
message: string;
25+
/** Thread ID for conversation continuity (reuse across calls for same task). */
26+
threadId?: string;
27+
/** Override AF stream URL (if stored in platform metadata). */
2228
afStreamUrl?: string;
23-
/** Source channel info (for routing responses back). */
24-
source: {
25-
channel: string;
26-
chatId: string;
27-
userId?: string;
28-
userName?: string;
29-
};
30-
/** Opaque platform data the connector needs in postResult. */
31-
platformContext: Record<string, unknown>;
29+
/** Human-readable task label for logging. */
30+
label: string;
31+
/** Opaque data the connector needs to post results back. */
32+
replyContext: Record<string, unknown>;
3233
}
3334

34-
/** Channel connector for receiving tasks from an external platform. */
35+
/** Channel connector — thin protocol translator. */
3536
export interface ChannelConnector {
36-
/** Short slug: paperclip, linear, github, webhook, etc. */
37+
/** Short slug: paperclip, linear, github, webhook */
3738
readonly name: string;
38-
/** Human-readable display name. */
39-
readonly displayName: string;
4039

4140
/**
42-
* Parse incoming webhook into a NormalizedTask.
43-
* Return null to skip (irrelevant event type).
44-
* Throw to reject with 400.
41+
* Parse incoming webhook → InboundTask.
42+
* Return null to acknowledge but skip.
4543
*/
4644
parseWebhook(
4745
headers: Record<string, string | string[] | undefined>,
4846
body: string,
49-
): Promise<NormalizedTask | null>;
47+
): Promise<InboundTask | null>;
5048

51-
/** Post agent response back to the originating channel. */
52-
postResult(task: NormalizedTask, resultText: string): Promise<void>;
53-
54-
/** Optional: check if the channel is reachable. */
55-
healthCheck?(): Promise<boolean>;
49+
/** Post agent response back to originating platform. */
50+
postResult(task: InboundTask, resultText: string): Promise<void>;
5651
}
57-
58-
/** Registry of available channel connectors. */
59-
export type ConnectorRegistry = Map<string, ChannelConnector>;
Lines changed: 42 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,163 +1,93 @@
11
/**
2-
* Linear channel connector.
2+
* Linear channel connector (thin).
33
*
4-
* Receives Linear webhooks (issue.create, issue.update, comment.create),
5-
* fetches issue context, and posts results back as comments.
6-
*
7-
* Setup: In Linear → Settings → API → Webhooks, add:
8-
* URL: https://your-gateway/webhook/linear
9-
* Events: Issues (created, updated), Comments (created)
4+
* Translates Linear webhooks → AF runtime call.
5+
* Linear webhook events: issue.create, issue.update, comment.create.
106
*/
117

12-
import type { ChannelConnector, NormalizedTask } from "../connector.js";
8+
import type { ChannelConnector, InboundTask } from "../connector.js";
139

1410
export interface LinearConnectorConfig {
1511
linearApiKey: string;
16-
/** Map of Linear team key → AF agent ID. e.g. { "ENG": "af-agent-uuid" } */
12+
/** Team key → AF agent ID. e.g. { "ENG": "af-uuid" } */
1713
agentMapping: Record<string, string>;
18-
/** Optional: AF stream URL override per agent. */
19-
streamUrlMapping?: Record<string, string>;
2014
}
2115

22-
// Minimal Linear GraphQL client
23-
async function linearQuery(
24-
apiKey: string,
25-
query: string,
26-
variables?: Record<string, unknown>,
27-
): Promise<unknown> {
16+
async function linearGql(apiKey: string, query: string, variables?: Record<string, unknown>): Promise<unknown> {
2817
const resp = await fetch("https://api.linear.app/graphql", {
2918
method: "POST",
30-
headers: {
31-
"Content-Type": "application/json",
32-
Authorization: apiKey,
33-
},
19+
headers: { "Content-Type": "application/json", Authorization: apiKey },
3420
body: JSON.stringify({ query, variables }),
3521
});
36-
if (!resp.ok) throw new Error(`Linear API failed (${resp.status})`);
22+
if (!resp.ok) throw new Error(`Linear API ${resp.status}`);
3723
const result = (await resp.json()) as { data?: unknown; errors?: unknown[] };
38-
if (result.errors) throw new Error(`Linear GraphQL: ${JSON.stringify(result.errors)}`);
24+
if (result.errors) throw new Error(JSON.stringify(result.errors));
3925
return result.data;
4026
}
4127

42-
async function linearGetIssue(apiKey: string, issueId: string): Promise<Record<string, unknown>> {
43-
const data = (await linearQuery(apiKey, `
44-
query($id: String!) {
45-
issue(id: $id) {
46-
id identifier title description priority priorityLabel
47-
state { name }
48-
team { key name }
49-
assignee { name }
50-
labels { nodes { name } }
51-
comments { nodes { body user { name } createdAt } }
52-
}
53-
}
54-
`, { id: issueId })) as { issue: Record<string, unknown> };
55-
return data.issue;
56-
}
57-
58-
async function linearAddComment(apiKey: string, issueId: string, body: string): Promise<void> {
59-
await linearQuery(apiKey, `
60-
mutation($issueId: String!, $body: String!) {
61-
commentCreate(input: { issueId: $issueId, body: $body }) {
62-
success
63-
}
64-
}
65-
`, { issueId, body });
66-
}
67-
6828
export class LinearConnector implements ChannelConnector {
6929
readonly name = "linear";
70-
readonly displayName = "Linear";
7130

7231
constructor(private config: LinearConnectorConfig) {}
7332

7433
async parseWebhook(
7534
_headers: Record<string, string | string[] | undefined>,
7635
body: string,
77-
): Promise<NormalizedTask | null> {
36+
): Promise<InboundTask | null> {
7837
const payload = JSON.parse(body) as {
7938
action: string;
8039
type: string;
81-
data: {
82-
id: string;
83-
title?: string;
84-
description?: string;
85-
identifier?: string;
86-
teamId?: string;
87-
issueId?: string; // for comments
88-
body?: string; // for comments
89-
[key: string]: unknown;
90-
};
91-
url?: string;
40+
data: Record<string, unknown>;
9241
};
9342

94-
// Only handle issue creates/updates and comment creates
9543
const isIssue = payload.type === "Issue" && ["create", "update"].includes(payload.action);
9644
const isComment = payload.type === "Comment" && payload.action === "create";
9745
if (!isIssue && !isComment) return null;
9846

99-
const issueId = isComment ? payload.data.issueId! : payload.data.id;
47+
const issueId = isComment ? payload.data.issueId as string : payload.data.id as string;
48+
49+
// Fetch issue for context
50+
const data = (await linearGql(this.config.linearApiKey, `
51+
query($id: String!) {
52+
issue(id: $id) {
53+
id identifier title description priorityLabel
54+
state { name }
55+
team { key }
56+
}
57+
}
58+
`, { id: issueId })) as { issue: Record<string, unknown> };
10059

101-
// Fetch full issue details
102-
const issue = await linearGetIssue(this.config.linearApiKey, issueId);
60+
const issue = data.issue;
10361
const team = issue.team as Record<string, unknown> | undefined;
10462
const teamKey = (team?.key as string) ?? "";
105-
106-
// Find AF agent for this team
10763
const afAgentId = this.config.agentMapping[teamKey];
108-
if (!afAgentId) return null; // No agent mapped for this team — skip
64+
if (!afAgentId) return null; // No agent for this team
10965

11066
const state = issue.state as Record<string, unknown> | undefined;
111-
const labels = issue.labels as { nodes: Array<{ name: string }> } | undefined;
112-
const comments = issue.comments as { nodes: Array<Record<string, unknown>> } | undefined;
113-
114-
// Build message
115-
const parts: string[] = [];
116-
parts.push("You have received a task from Linear.\n");
117-
parts.push(`## Task: ${issue.identifier}${issue.title}`);
118-
parts.push(`- **Priority:** ${issue.priorityLabel ?? "medium"}`);
119-
parts.push(`- **Status:** ${state?.name ?? "unknown"}`);
120-
if (labels?.nodes?.length) {
121-
parts.push(`- **Labels:** ${labels.nodes.map((l) => l.name).join(", ")}`);
122-
}
123-
if (issue.description) {
124-
parts.push(`\n### Description\n${issue.description}`);
125-
}
126-
if (comments?.nodes?.length) {
127-
parts.push("\n### Recent Comments");
128-
for (const c of comments.nodes.slice(-5)) {
129-
const user = c.user as Record<string, unknown> | undefined;
130-
parts.push(`- **${user?.name ?? "Unknown"}:** ${c.body}`);
131-
}
132-
}
133-
if (isComment && payload.data.body) {
134-
parts.push(`\n### New Comment (trigger)\n${payload.data.body}`);
135-
}
136-
parts.push("\n## Instructions\nComplete this task. Provide a clear summary of your work.");
67+
const parts = [
68+
`Task: ${issue.identifier}${issue.title}`,
69+
`Priority: ${issue.priorityLabel ?? "medium"} | Status: ${state?.name ?? "unknown"}`,
70+
];
71+
if (issue.description) parts.push(`Description: ${issue.description}`);
72+
if (isComment && payload.data.body) parts.push(`New comment: ${payload.data.body}`);
13773

13874
return {
139-
threadId: issueId, // reuse Linear issue ID as thread for continuity
140-
taskIdentifier: (issue.identifier as string) ?? issueId,
141-
message: parts.join("\n"),
14275
afAgentId,
143-
afStreamUrl: this.config.streamUrlMapping?.[afAgentId],
144-
source: {
145-
channel: "linear",
146-
chatId: teamKey,
147-
userName: (issue.assignee as Record<string, unknown>)?.name as string | undefined,
148-
},
149-
platformContext: { issueId, teamKey },
76+
message: parts.join("\n"),
77+
threadId: issueId,
78+
label: (issue.identifier as string) ?? issueId,
79+
replyContext: { issueId, teamKey },
15080
};
15181
}
15282

153-
async postResult(task: NormalizedTask, resultText: string): Promise<void> {
154-
const issueId = task.platformContext.issueId as string;
83+
async postResult(task: InboundTask, resultText: string): Promise<void> {
84+
const issueId = task.replyContext.issueId as string;
15585
if (issueId && resultText) {
156-
await linearAddComment(
157-
this.config.linearApiKey,
158-
issueId,
159-
`**Agent Response:**\n\n${resultText}`,
160-
);
86+
await linearGql(this.config.linearApiKey, `
87+
mutation($issueId: String!, $body: String!) {
88+
commentCreate(input: { issueId: $issueId, body: $body }) { success }
89+
}
90+
`, { issueId, body: `**Agent Response:**\n\n${resultText}` });
16191
}
16292
}
16393
}

0 commit comments

Comments
 (0)