Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.DS_Store
node_modules/
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,33 @@

**Agent Workforce.**
---
A collection of proactive agents
A collection of proactive agents. Each folder is a deployable agent — a typed
`persona.ts` (what it listens to + how it runs) and an `agent.ts` handler (what
it does). The persona compiles to `persona.json`; deploy with
`agentworkforce deploy ./<agent>/persona.json --mode cloud`.

## The agents

| Agent | Fires on | What it does |
| --- | --- | --- |
| [**granola**](granola/) | a new Granola note (Nango sync → `file.created`) | Detects prospect calls, files a Linear issue with the ask, and opens a GitHub PR implementing it. |
| [**hn-monitor**](hn-monitor/) | schedule (2×/day) | Scans Hacker News for your topics and posts a digest to Slack. |
| [**linear**](linear/) | Linear `issue.create` (labelled) / `comment.create` | Implements the issue and opens a GitHub PR; comments the PR link back. |
| [**review**](review/) | GitHub PR opened / updated / reviewed / CI finished | Reviews the PR, fixes the issues it (and other bots) find, resolves failing CI and merge conflicts, DMs you when it's ready, and merges once you approve. |
| [**spotify-releases**](spotify-releases/) | schedule (daily) | Checks for new releases from artists you follow and DMs them to you. |
| [**vendor-monitor**](vendor-monitor/) | schedule (weekday mornings) | Watches the vendors in your stack for new releases and posts changes to your team channel. |

## How they're built

- **Typed authoring.** Personas use `definePersona` from `@agentworkforce/persona-kit`, so `integrations.<provider>.triggers[].on` autocompletes the provider's real events and is linted at deploy.
- **Integrations are VFS-backed.** Agents read/write providers (Slack, Linear, GitHub, Gmail/Google-Mail, Granola…) through the Relayfile VFS and the typed `ctx` clients — no direct API calls or tokens to manage.
- **Repos are materialized, not cloned.** For agents that touch code, the cloud materializes the GitHub repo into the sandbox (`ctx.sandbox.cwd`) via Relayfile, so handlers never run `git clone` — they just hand the work to the coding agent (`ctx.harness.run`).

## Run one locally

```sh
npm install
npm run typecheck # tsc over every agent
agentworkforce persona compile ./hn-monitor/persona.ts
agentworkforce deploy ./hn-monitor/persona.json --mode cloud --input SLACK_CHANNEL=C0123ABCD
```
127 changes: 127 additions & 0 deletions granola/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* granola-prospect handler.
*
* a new Granola note syncs in (storage `file.created` at /granola/notes/…)
* → read the note's transcript from the VFS
* → ask the model "is this a prospect call, and what did they ask for?"
* → if yes: file a Linear issue, then have the coding agent open a PR for it
*/
import { handler, type WorkforceCtx } from '@agentworkforce/runtime';

interface Ask {
isProspect: boolean;
title: string;
summary: string;
}

export default handler(async (ctx, event) => {
// Notes arrive via the Nango sync as storage events; the clock isn't one.
if (event.source === 'cron') return;

const notePath = readNotePath(event.payload);
if (!notePath || !notePath.includes('/granola/notes/')) return; // ignore folders/other writes
if (!ctx.linear) throw new Error('granola-prospect requires the linear integration');

const transcript = await readNote(ctx, notePath);
if (!transcript) return;

const ask = await classify(ctx, transcript);
if (!ask.isProspect) {
ctx.log('info', 'granola-prospect.not-a-prospect', {});
return;
}

const teamId = await resolveTeamId(ctx);
const issue = await ctx.linear.createIssue({ teamId, title: ask.title, description: ask.summary });
ctx.log('info', 'granola-prospect.issue-created', { url: issue.url });

// The cloud materializes the github repo into the sandbox (ctx.sandbox.cwd)
// via relayfile — no clone, no gh/git. The GitHub integration opens the PR.
const run = await ctx.harness.run({
cwd: ctx.sandbox.cwd,
prompt: `A prospect asked for the following. Comprehensively implement it (every change needed to fully address the ask), then open a GitHub pull request with your changes — the GitHub integration opens it, do not use git or the \`gh\` CLI. Put the PR URL on the last line.\n\nLinear issue: ${issue.url}\n\n${ask.summary}`
});

const prUrl = run.output.match(/https?:\/\/\S*\/pull\/\d+/g)?.pop();
if (prUrl) await ctx.linear.comment(issue.id, `:rocket: Implementation PR: ${prUrl}`);
});

/** A storage `file.created` event carries the VFS path of the file that landed. */
function readNotePath(payload: unknown): string | undefined {
const p = payload as { path?: string; relayfilePath?: string; data?: { path?: string } } | null;
return p?.path ?? p?.relayfilePath ?? p?.data?.path;
}

/** Read the synced note JSON and pull out its transcript / content text. */
async function readNote(ctx: WorkforceCtx, path: string): Promise<string | undefined> {
try {
const note = JSON.parse(await ctx.files.read(path)) as {
transcript?: string;
content?: string;
summary?: string;
};
return note.transcript ?? note.content ?? note.summary;
} catch {
return undefined;
}
}

async function classify(ctx: WorkforceCtx, transcript: string): Promise<Ask> {
const prompt = [
'Read this meeting transcript. Decide if it is a sales/prospect call where the',
'prospect asked for a feature or change. Reply with JSON only:',
'{"isProspect": boolean, "title": "short issue title", "summary": "what they asked for"}',
'',
transcript.slice(0, 8000)
].join('\n');
try {
// Models often wrap JSON in ```json fences — strip them before parsing.
const raw = (await ctx.llm.complete(prompt, { maxTokens: 400 })).replace(/```json\s*|```/g, '').trim();
return JSON.parse(raw) as Ask;
} catch {
return { isProspect: false, title: '', summary: '' };
}
Comment on lines +77 to +83
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

LLMs frequently wrap JSON responses in markdown code blocks (e.g., ```json ... ```). If this happens, a direct JSON.parse will throw an error and silently return { isProspect: false }. Stripping any markdown code block formatting before parsing makes the JSON extraction significantly more robust.

  try {
    const response = await ctx.llm.complete(prompt, { maxTokens: 400 });
    const cleaned = response.replace(/```json\s*|```/g, '').trim();
    return JSON.parse(cleaned) as Ask;
  } catch {
    return { isProspect: false, title: '', summary: '' };
  }

Comment on lines +69 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate LLM output shape before treating it as Ask.

JSON.parse success doesn’t guarantee required fields exist or have correct types; isProspect: true with missing/invalid title/summary can break or degrade issue creation.

Proposed fix
 async function classify(ctx: WorkforceCtx, transcript: string): Promise<Ask> {
@@
   try {
-    return JSON.parse((await ctx.llm.complete(prompt, { maxTokens: 400 })).trim()) as Ask;
+    const raw = JSON.parse((await ctx.llm.complete(prompt, { maxTokens: 400 })).trim()) as Partial<Ask>;
+    if (
+      typeof raw?.isProspect === 'boolean' &&
+      typeof raw?.title === 'string' &&
+      typeof raw?.summary === 'string'
+    ) {
+      return {
+        isProspect: raw.isProspect,
+        title: raw.title.trim(),
+        summary: raw.summary.trim()
+      };
+    }
+    return { isProspect: false, title: '', summary: '' };
   } catch {
     return { isProspect: false, title: '', summary: '' };
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function classify(ctx: WorkforceCtx, transcript: string): Promise<Ask> {
const prompt = [
'Read this meeting transcript. Decide if it is a sales/prospect call where the',
'prospect asked for a feature or change. Reply with JSON only:',
'{"isProspect": boolean, "title": "short issue title", "summary": "what they asked for"}',
'',
transcript.slice(0, 8000)
].join('\n');
try {
return JSON.parse((await ctx.llm.complete(prompt, { maxTokens: 400 })).trim()) as Ask;
} catch {
return { isProspect: false, title: '', summary: '' };
}
async function classify(ctx: WorkforceCtx, transcript: string): Promise<Ask> {
const prompt = [
'Read this meeting transcript. Decide if it is a sales/prospect call where the',
'prospect asked for a feature or change. Reply with JSON only:',
'{"isProspect": boolean, "title": "short issue title", "summary": "what they asked for"}',
'',
transcript.slice(0, 8000)
].join('\n');
try {
const raw = JSON.parse((await ctx.llm.complete(prompt, { maxTokens: 400 })).trim()) as Partial<Ask>;
if (
typeof raw?.isProspect === 'boolean' &&
typeof raw?.title === 'string' &&
typeof raw?.summary === 'string'
) {
return {
isProspect: raw.isProspect,
title: raw.title.trim(),
summary: raw.summary.trim()
};
}
return { isProspect: false, title: '', summary: '' };
} catch {
return { isProspect: false, title: '', summary: '' };
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@granola/agent.ts` around lines 69 - 81, The classify function parses LLM JSON
into an Ask but doesn’t validate fields; update classify to validate the parsed
object from ctx.llm.complete before returning it: after JSON.parse, confirm the
result is an object with boolean isProspect and string title and summary (and
sanitize/trim them), return that validated Ask only if checks pass, otherwise
fall back to { isProspect: false, title: '', summary: '' }; keep error handling
for parse failures and ensure any non-conforming values (e.g., missing or wrong
types) trigger the safe fallback.

}

/**
* Which Linear team to file under. An explicit LINEAR_TEAM_ID wins; otherwise
* we auto-pick when the `fetch-teams` sync shows exactly one team, and block
* (with a helpful list) when it's ambiguous.
*/
async function resolveTeamId(ctx: WorkforceCtx): Promise<string> {
const configured = input(ctx, 'LINEAR_TEAM_ID');
if (configured) return configured;

const teams = await listLinearTeams(ctx);
if (teams.length === 1) return teams[0].id;

const found = teams.length ? ` Teams: ${teams.map((t) => `${t.name} (${t.id})`).join(', ')}.` : '';
throw new Error(
`Can't pick a Linear team automatically — found ${teams.length}. Set LINEAR_TEAM_ID.${found}`
);
}

/** Linear teams the `fetch-teams` sync materialized at /linear/teams/*.json. */
async function listLinearTeams(ctx: WorkforceCtx): Promise<Array<{ id: string; name: string }>> {
const root = process.env.RELAYFILE_MOUNT_ROOT?.replace(/\/$/, '') ?? '';
const { output } = await ctx.sandbox.exec(
`find ${root}/linear/teams -maxdepth 1 -name '*.json' -not -name '_index.json' 2>/dev/null || true`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Avoid unquoted shell interpolation for RELAYFILE_MOUNT_ROOT when building the find command; quote/escape the path to prevent command injection and path parsing errors.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At granola/agent.ts, line 106:

<comment>Avoid unquoted shell interpolation for `RELAYFILE_MOUNT_ROOT` when building the `find` command; quote/escape the path to prevent command injection and path parsing errors.</comment>

<file context>
@@ -82,6 +81,42 @@ async function classify(ctx: WorkforceCtx, transcript: string): Promise<Ask> {
+async function listLinearTeams(ctx: WorkforceCtx): Promise<Array<{ id: string; name: string }>> {
+  const root = process.env.RELAYFILE_MOUNT_ROOT?.replace(/\/$/, '') ?? '';
+  const { output } = await ctx.sandbox.exec(
+    `find ${root}/linear/teams -maxdepth 1 -name '*.json' -not -name '_index.json' 2>/dev/null || true`
+  );
+  const teams: Array<{ id: string; name: string }> = [];
</file context>

);
Comment on lines +104 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid interpolating env-derived paths directly into a shell command.

RELAYFILE_MOUNT_ROOT is injected into ctx.sandbox.exec(...) without escaping, which is a command-injection risk.

Proposed fix
 async function listLinearTeams(ctx: WorkforceCtx): Promise<Array<{ id: string; name: string }>> {
   const root = process.env.RELAYFILE_MOUNT_ROOT?.replace(/\/$/, '') ?? '';
+  const teamsDir = `${root}/linear/teams`;
+  const safeTeamsDir = JSON.stringify(teamsDir);
   const { output } = await ctx.sandbox.exec(
-    `find ${root}/linear/teams -maxdepth 1 -name '*.json' -not -name '_index.json' 2>/dev/null || true`
+    `find ${safeTeamsDir} -maxdepth 1 -name '*.json' -not -name '_index.json' 2>/dev/null || true`
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const root = process.env.RELAYFILE_MOUNT_ROOT?.replace(/\/$/, '') ?? '';
const { output } = await ctx.sandbox.exec(
`find ${root}/linear/teams -maxdepth 1 -name '*.json' -not -name '_index.json' 2>/dev/null || true`
);
const root = process.env.RELAYFILE_MOUNT_ROOT?.replace(/\/$/, '') ?? '';
const teamsDir = `${root}/linear/teams`;
const safeTeamsDir = JSON.stringify(teamsDir);
const { output } = await ctx.sandbox.exec(
`find ${safeTeamsDir} -maxdepth 1 -name '*.json' -not -name '_index.json' 2>/dev/null || true`
);
🧰 Tools
🪛 OpenGrep (1.22.0)

[ERROR] 105-107: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@granola/agent.ts` around lines 104 - 107, The code interpolates
RELAYFILE_MOUNT_ROOT into a shell string passed to ctx.sandbox.exec (via the
root constant), which is a command-injection risk; replace this by constructing
a safe, validated path and passing it as an argument instead of raw string
interpolation: compute a normalized path with path.resolve/path.join (use
RELAYFILE_MOUNT_ROOT -> root), validate it contains no shell metacharacters (or
use a shell-escaping utility), and call ctx.sandbox.exec in a way that supplies
the path as a single argument (or use an execFile-style API) rather than
embedding it inside the backticked command string `find ${root}/...`. Ensure you
update the call site that builds the `find` command to use the sanitized/escaped
path variable or argument form so ctx.sandbox.exec never receives untrusted
interpolated shell content.

const teams: Array<{ id: string; name: string }> = [];
for (const file of output.split('\n').map((l) => l.trim()).filter(Boolean)) {
try {
const t = JSON.parse(await ctx.files.read(file)) as { id?: string; name?: string };
if (t.id) teams.push({ id: t.id, name: t.name ?? t.id });
} catch {
/* skip unreadable entries */
}
}
return teams;
}

// ── tiny helpers ────────────────────────────────────────────────────────────
function input(ctx: WorkforceCtx, name: string): string | undefined {
const spec = ctx.persona.inputSpecs?.[name];
const v = process.env[spec?.env ?? name] ?? ctx.persona.inputs?.[name] ?? spec?.default;
return v && v.trim() ? v : undefined;
}
48 changes: 48 additions & 0 deletions granola/persona.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { definePersona } from '@agentworkforce/persona-kit';

/**
* Granola Agent — when a Granola meeting recording lands, detects prospect
* calls, files a Linear issue with what they asked for, and opens a GitHub PR
* implementing it.
*/
export default definePersona({
id: 'granola-prospect',
intent: 'relay-orchestrator',
tags: ['discovery', 'implementation'],
description: 'When a Granola recording lands, detects prospect calls, files a Linear issue with the ask, and opens a GitHub PR implementing it.',
cloud: true,
useSubscription: true,

integrations: {
// Granola has no realtime webhook yet, so notes arrive via the Nango
// `granola-relay:fetch-notes` sync, which writes each note to the VFS at
// /granola/notes/<id>.json and fires a storage `file.created` event.
granola: { triggers: [{ on: 'file.created' }] },
linear: {},
// The cloud materializes this repo into the sandbox (ctx.sandbox.cwd) via
// relayfile — the agent never clones it.
github: { scope: { repo: 'your-org/your-repo' } }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The GitHub repo scope is still a placeholder (your-org/your-repo), which can prevent this agent from operating on the intended repository.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At granola/persona.ts, line 24:

<comment>The GitHub repo scope is still a placeholder (`your-org/your-repo`), which can prevent this agent from operating on the intended repository.</comment>

<file context>
@@ -0,0 +1,42 @@
+    linear: {},
+    // The cloud materializes this repo into the sandbox (ctx.sandbox.cwd) via
+    // relayfile — the agent never clones it.
+    github: { scope: { repo: 'your-org/your-repo' } }
+  },
+
</file context>

},

inputs: {
// Optional: auto-resolved from the `fetch-teams` sync when there's exactly
// one Linear team. Only needed to disambiguate when you have several.
LINEAR_TEAM_ID: {
description: 'Linear team to file prospect issues under (only needed if you have multiple teams).',
env: 'LINEAR_TEAM_ID',
optional: true
}
},

harness: 'claude',
model: 'claude-sonnet-4-6',
systemPrompt: 'Turn prospect asks from meeting transcripts into a Linear issue and a small implementing PR.',
harnessSettings: {
reasoning: 'high',
timeoutSeconds: 1800,
sandboxMode: 'workspace-write',
workspaceWriteNetworkAccess: true
},

onEvent: './agent.ts'
});
81 changes: 81 additions & 0 deletions hn-monitor/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* hn-monitor handler.
*
* fetch the HN front page
* → keep stories whose title matches one of your TOPICS
* → drop ones already posted (durable memory)
* → summarize with ctx.llm
* → post to Slack
*/
import { handler, type WorkforceCtx } from '@agentworkforce/runtime';

interface Story {
id: number;
title: string;
url: string;
points: number;
}

export default handler(async (ctx, event) => {
if (event.source !== 'cron') return;
if (!ctx.slack) throw new Error('hn-monitor requires the slack integration');

const channel = input(ctx, 'SLACK_CHANNEL');
if (!channel) throw new Error('SLACK_CHANNEL is required');
const topics = list(input(ctx, 'TOPICS')).map((t) => t.toLowerCase());

const stories = await fetchFrontPage();
const matches = stories.filter((s) => topics.some((t) => s.title.toLowerCase().includes(t)));

const seen = await loadSeen(ctx);
const fresh = matches.filter((s) => !seen.includes(s.id));
if (fresh.length === 0) {
ctx.log('info', 'hn-monitor.nothing-new', { matched: matches.length });
return;
}

await ctx.slack.post(channel, await summarize(ctx, fresh));
await saveSeen(ctx, [...seen, ...fresh.map((s) => s.id)].slice(-200));
});

/** Top ~30 front-page stories via the public HN Algolia API. */
async function fetchFrontPage(): Promise<Story[]> {
const res = await fetch('https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=30');
const data = (await res.json()) as { hits: Array<{ objectID: string; title: string; url: string | null; points: number }> };
return data.hits.map((h) => ({
id: Number(h.objectID),
title: h.title,
url: h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`,
points: h.points
}));
}
Comment on lines +42 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the Hacker News Algolia API is temporarily down or a network error occurs, the unhandled exception will crash the cron job. Wrapping the API call in a try-catch block and returning an empty array on failure makes the monitor much more resilient.

async function fetchFrontPage(): Promise<Story[]> {
  try {
    const res = await fetch('https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=30');
    if (!res.ok) return [];
    const data = (await res.json()) as { hits: Array<{ objectID: string; title: string; url: string | null; points: number }> };
    return data.hits.map((h) => ({
      id: Number(h.objectID),
      title: h.title,
      url: h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`,
      points: h.points
    }));
  } catch {
    return [];
  }
}


async function summarize(ctx: WorkforceCtx, stories: Story[]): Promise<string> {
const lines = stories.map((s) => `- ${s.title} (${s.points} pts) ${s.url}`).join('\n');
const digest = await ctx.llm.complete(
`Write a tight Slack digest (mrkdwn, one bullet per story, lead with why it matters):\n\n${lines}`,
{ maxTokens: 500 }
);
return `:newspaper: *Hacker News* — ${stories.length} new match(es)\n${digest.trim()}`;
}

// ── tiny helpers ────────────────────────────────────────────────────────────
function list(raw: string | undefined): string[] {
return (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean);
}
function input(ctx: WorkforceCtx, name: string): string | undefined {
const spec = ctx.persona.inputSpecs?.[name];
const v = process.env[spec?.env ?? name] ?? ctx.persona.inputs?.[name] ?? spec?.default;
return v && v.trim() ? v : undefined;
}
async function loadSeen(ctx: WorkforceCtx): Promise<number[]> {
const [item] = await ctx.memory.recall('hn-monitor seen', { tags: ['hn-monitor:seen'], limit: 1 });
try {
return item ? (JSON.parse(item.content) as number[]) : [];
} catch {
return [];
}
}
async function saveSeen(ctx: WorkforceCtx, ids: number[]): Promise<void> {
await ctx.memory.save(JSON.stringify(ids), { tags: ['hn-monitor:seen'], scope: 'workspace' });
}
38 changes: 38 additions & 0 deletions hn-monitor/persona.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { definePersona } from '@agentworkforce/persona-kit';

/**
* Hacker News Monitor — scans HN a few times a day for the topics you care
* about and posts a short digest to Slack.
*/
export default definePersona({
id: 'hn-monitor',
intent: 'relay-orchestrator',
tags: ['discovery'],
description: 'Scans Hacker News a few times a day for topics you care about and posts a summary to Slack.',
cloud: true,

// Runs on a clock (09:00 & 17:00), not an event. No triggers needed.
schedules: [{ name: 'scan', cron: '0 9,17 * * *', tz: 'America/New_York' }],

// `slack` gives the handler the ctx.slack client to post the digest.
integrations: { slack: {} },

inputs: {
TOPICS: {
description: 'Comma-separated keywords to watch for (matched against story titles).',
env: 'TOPICS',
default: 'agents,ai,typescript,developer tools'
},
SLACK_CHANNEL: { description: 'Slack channel id to post the digest to.', env: 'SLACK_CHANNEL' }
},

// ctx.llm uses this model to summarize the matching stories.
harness: 'claude',
model: 'claude-haiku-4-5-20251001',
systemPrompt: 'Summarize Hacker News stories into a short, skimmable Slack digest.',
harnessSettings: { reasoning: 'low', timeoutSeconds: 120 },

memory: { enabled: true, scopes: ['workspace'], ttlDays: 7 },

onEvent: './agent.ts'
});
Loading