Kampagnen anlegen, Cold-Mail-Copy nach SOP, Leads qualifizieren, recherchieren und Mails generieren, Launch vorbereiten – mit MCP-Verbindung und Live-Ansicht…

Ein Marketplace mit zwei Plugins für Kunden der Outreach-Plattform – für Claude (App, claude.ai, Cowork, Claude Code) und für ChatGPT Desktop und Codex.
| Plugin | Inhalt | Für wen |
|---|---|---|
| outreach | Kampagnen anlegen und ändern, Cold-Mail-Copy nach SOP, Leads importieren, qualifizieren, recherchieren, Mails generieren und prüfen, Launch vorbereiten. Bringt die MCP-Verbindung akquise (outreach.akquise.de) und in Claude Code die Live-Ansicht (Lead-Vorschau, Fortschritt der Läufe) mit. | alle |
| datenbeschaffung | Leads beschaffen mit Apify oder Outscraper im eigenen Konto, Liste prüfen, als CSV bzw. per import_leads übernehmen. | wer Leads selbst beschafft |
Die Skills passen zum Outreach-MCP mit 32 Tools und 7 Prompts. Der MCP verarbeitet Leads und verwaltet Kampagnen und Listen; er scrapt nicht. Versand läuft beim Kunden (Instantly o. Ä.), nicht in der Plattform.
Claude Code
/plugin marketplace add RankM8/outreach-plugins
/plugin install outreach@outreach-plugins
/plugin install datenbeschaffung@outreach-plugins (optional)
Claude App / claude.ai / Cowork: Customize → Plugins → Add marketplace → RankM8/outreach-plugins → outreach (und optional datenbeschaffung) installieren → im Plugin unter „Connectors“ die Verbindung akquise verbinden.
ChatGPT Desktop / Codex
codex plugin marketplace add RankM8/outreach-plugins
danach unter /plugins installieren und neue Session starten.
Cursor & andere Agenten (nur Skills): npx skills add RankM8/outreach-plugins, die MCP-Verbindung separat über die App-Seite „MCP & Skills“.
Nach der Installation: Skill outreach-setup aufrufen (bzw. datenbeschaffung-setup für das Plugin datenbeschaffung). Er prüft die Verbindung, räumt alte Skill-Kopien auf und zeigt die ersten Schritte.
Kunden einer anderen Outreach-App als Akquise: Die mitgelieferte Verbindung akquise unverbunden lassen und den MCP der eigenen App über deren Seite „MCP & Skills“ hinzufügen. Skills und Live-Ansicht erkennen ihn an seinen Tools.
Updates: Skill outreach-update. In Claude Code am besten Auto-Update einschalten (/plugin → Marketplaces → outreach-plugins).
.claude-plugin/marketplace.json Marketplace für Claude (liest Codex mit)
.agents/plugins/marketplace.json Marketplace für ChatGPT/Codex
plugins/
outreach/
.claude-plugin/plugin.json Manifest Claude
plugin.json Manifest portabel (OpenAI)
.mcp.json / mcp.json MCP-Verbindung akquise (Claude / OpenAI)
hooks/ Live-Ansicht für Claude Code (register.tsx, model.ts)
types/ tests/ Typen und Tests der Live-Ansicht
skills/
outreach Einstieg, leitet weiter
outreach-setup Einrichtung und Prüfung
outreach-update Aktualisieren
outreach-campaign Kampagne bauen und ändern nach der Cold-Mailing-SOP
outreach-copy Cold-Mail-Copy-Regeln (verbindlich für campaign, generate, verify)
outreach-import Leads importieren
outreach-lists Listen verwalten
outreach-qualify Leads qualifizieren
outreach-research Leads recherchieren
outreach-generate Mail-Variablen erzeugen
outreach-verify Review (approve/reject)
outreach-pipeline voller serverseitiger Durchlauf
outreach-launch Domains, Postfächer, Warm-up, Instantly, Auswertung, Optimierung
datenbeschaffung/
.claude-plugin/plugin.json plugin.json
skills/
datenbeschaffung Einstieg: Setup → ICP → Weg A–E → Weg → Qualität
datenbeschaffung-setup Einrichtung: Outreach-Verbindung, Apify, Outscraper prüfen
datenbeschaffung-update Aktualisieren
weg-* die Beschaffungswege (Google, Apollo, Maps, Instagram, Plattformen …)
outscraper-bulk sehr große Volumina
impressum-enrichment kontaktseiten-fallback enrichment-waterfall
listen-qualitaet Pflicht-Endstation: Dedup → Verifizierung → Stichprobe → Übergabe
datenbeschaffung-referenzen geteilte Referenzen und Skripte
(die Skills lesen über ../datenbeschaffung-referenzen/)
version in beiden Manifesten des betroffenen Plugins erhöhen (.claude-plugin/plugin.json und plugin.json) und CHANGELOG.md ergänzen. Ohne neue Version bekommen installierte Plugins nichts Neues.claude plugin test plugins/outreach; Manifeste: claude plugin validate .bin/-Ordner in einem Plugin (claude.ai lehnt das ganze Plugin sonst ab), keine Symlinks.datenbeschaffung-referenzen/references/apify-actors.md, kosten.md) tragen ein „zuletzt geprüft“-Datum.hooks/register.tsx 1075 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ImportView, LocalRun, RunView, StageView, Verdict } from '../types'
5import {
6 BRAND,
7 FIT_LABEL,
8 RUN_STATUS_LABEL,
9 STAGE_LABEL,
10 appLink,
11 bar,
12 importFrom,
13 importOutcome,
14 leadsThrough,
15 leadsFrom,
16 payloadOf,
17 runFrom,
18 type LeadList,
19 type LeadRow,
20} from './model'
21
22/**
23 * The outreach MCP tools' results drawn readable and clickable in the chat,
24 * and the lead runs and imports of this chat as one band above the prompt. The
25 * band asks the server itself only for those, every POLL_MS, until each has ended.
26 */
27
28const PREVIEW_ROWS = 6
29const POLL_MS = 20_000
30/** Rounds without a usable answer after which the band stops asking for a run. */
31const MAX_MISSES = 3
32/** How long a run that ended stays in the band. */
33const LINGER_MS = 120_000
34
35const runs = atom({ plugin: 'outreach', key: 'runs' } as const, {})
36const imports = atom({ plugin: 'outreach', key: 'imports' } as const, {})
37const locals = atom({ plugin: 'outreach', key: 'locals' } as const, {})
38const origins = atom({ plugin: 'outreach', key: 'origins' } as const, {})
39const frame = atom({ plugin: 'outreach', key: 'frame' } as const, 0)
40const folded = atom({ plugin: 'outreach', key: 'folded' } as const, false)
41let configuredServer = 'akquise'
42let poller: { cancel: () => void } | null = null
43const missedRounds = new Map<string, number>()
44let spinner: { cancel: () => void } | null = null
45
46let base = 'https://outreach.akquise.de'
47
48/**
49 * Which MCP servers speak the outreach tools. A server is recognised by
50 * what it offers, not by its name: every customer names it as they like when
51 * setting it up ("akquise", "listm8", "Outreach" ...), and one session may hold
52 * several (a paid account and a local one). Cached per session; a server first
53 * seen later (still connecting at start) is looked up when its first call comes.
54 */
55const SIGNATURE = ['list_leads', 'start_lead_run', 'get_lead_run_status'] as const
56const OUR_TOOLS = new Set([
57 'list_leads', 'search_leads', 'get_lead_data', 'start_lead_run', 'get_lead_run_status', 'cancel_lead_run',
58 'list_lead_runs', 'import_leads', 'get_job_status', 'write_lead_details', 'save_lead_variables',
59 'approve_lead_variables', 'reject_lead_variables',
60])
61let knownServers = new Map<string, boolean>()
62
63type Recognized = { server: string; name: string }
64
65const parseTool = (tool: string): Recognized | null => {
66 const m = /^mcp__(.+)__([a-z_]+)$/.exec(tool)
67 return m && m[1] !== undefined && m[2] !== undefined ? { server: m[1], name: m[2] } : null
68}
69
70/** Re-reads the tool list and notes, per MCP server, whether it offers the whole signature. */
71const refreshServers = async ($: EngineInterface) => {
72 const offered = new Map<string, Set<string>>()
73 let tools: Awaited<ReturnType<EngineInterface['tool']['list']>> = []
74 try {
75 tools = await $.tool.list()
76 } catch {
77 return // No list now: keep what is known; the configured server still works.
78 }
79 for (const info of tools) {
80 const parsed = info.mcp ? parseTool(info.name) : null
81 if (parsed === null) continue
82 const names = offered.get(parsed.server) ?? new Set<string>()
83 names.add(parsed.name)
84 offered.set(parsed.server, names)
85 }
86 for (const [srv, names] of offered) knownServers.set(srv, SIGNATURE.every(n => names.has(n)))
87}
88
89/** The outreach call behind a tool name, or null when the tool is not one of ours. */
90const recognize = async ($: EngineInterface, tool: string): Promise<Recognized | null> => {
91 const parsed = parseTool(tool)
92 if (parsed === null || !OUR_TOOLS.has(parsed.name)) return null
93 if (parsed.server === configuredServer) return parsed
94 if (!knownServers.has(parsed.server)) await refreshServers($)
95 return knownServers.get(parsed.server) === true ? parsed : null
96}
97
98/** Every server recognised now, the configured one included. */
99const akquiseServers = async ($: EngineInterface): Promise<string[]> => {
100 await refreshServers($)
101 const found = [...knownServers].filter(([, ok]) => ok).map(([srv]) => srv)
102 return found.includes(configuredServer) ? found : [...found, configuredServer]
103}
104
105type El = ReturnType<EngineInterface['ui']['resolve']>
106
107/** A run's page: the link its server sent, else built from the configured app address. */
108const runHref = (run: RunView) => run.appUrl ?? appLink(base, `/campaigns/${run.campaignId}/leads`)
109
110const leadPreview = (el: El, list: LeadList) => {
111 const { Box, Text, Link } = el
112 const shown = list.leads.slice(0, PREVIEW_ROWS)
113 const more = list.total - shown.length
114 const cid = list.campaign?.id
115 const leadHref = (lead: LeadRow) => lead.appUrl ?? appLink(base, cid ? `/campaigns/${cid}/leads/${lead.id}` : `/leads/${lead.id}`)
116
117 return (
118 <Box flexDirection="column" borderStyle="round" borderColor={BRAND.accent} paddingX={1}>
119 <Box gap={1}>
120 <Text bold>{`${list.total} ${list.total === 1 ? 'Lead' : 'Leads'}`}</Text>
121 {list.campaign && <Text dimColor>{`in „${list.campaign.name}“`}</Text>}
122 </Box>
123 {shown.map(lead => {
124 const fit = FIT_LABEL[lead.fit]
125 return (
126 <Box key={`lead-${lead.id}`} gap={1}>
127 <Text color={fit?.color ?? 'gray'}>●</Text>
128 <Box width={28}><Text wrap="truncate-end">{lead.company}</Text></Box>
129 <Box width={12}><Text dimColor wrap="truncate-end">{lead.city || '–'}</Text></Box>
130 <Box width={14}><Text color={fit?.color} dimColor={fit === undefined} wrap="truncate-end">{fit?.text ?? 'nicht bewertet'}</Text></Box>
131 <Box width={4}><Text dimColor>{lead.score === null ? '' : String(lead.score)}</Text></Box>
132 <Link href={leadHref(lead)} label="Öffnen ↗" />
133 </Box>
134 )
135 })}
136 <Box gap={1}>
137 {more > 0 && <Text dimColor>{`… ${more} weitere`}</Text>}
138 <Link href={list.campaign?.appUrl ?? appLink(base, cid ? `/campaigns/${cid}/leads` : '/leads')} label="Alle in der App ↗" />
139 </Box>
140 </Box>
141 )
142}
143
144/** In the chat a started run is one line; its progress lives in the band. */
145const startLine = (el: El, run: RunView) => {
146 const { Box, Text, Link } = el
147 const stages = run.stages.map(s => STAGE_LABEL[s.stage] ?? s.stage).join(' + ')
148 const budget = run.budgetUsd === null ? '' : ` · Budget ${run.budgetUsd.toFixed(2)} $`
149
150 return (
151 <Box gap={1}>
152 <Text color={BRAND.accent}>▶</Text>
153 <Text>{`Lead-Run gestartet · ${run.leadTotal} Leads · ${stages}${budget}`}</Text>
154 <Text dimColor>· Fortschritt über dem Prompt</Text>
155 {(run.appUrl !== null || run.campaignId > 0) && <Link href={runHref(run)} label="Öffnen ↗" />}
156 </Box>
157 )
158}
159
160const RUN_COLOR = (run: RunView) =>
161 !run.isTerminal ? BRAND.accent : run.status === 'completed' ? BRAND.done : run.status === 'cancelled' ? 'gray' : BRAND.warn
162
163/** One run in the band: the stage it is in, done against the lead total, and why it stopped if it did. */
164const STAGE_SHORT: Record<string, string> = { qualification: 'Qual.', research: 'Rech.', email: 'Mail' }
165
166/** Cells of a two-part bar: finished, set aside, and what is left, over `width`. */
167const barCells = (total: number, finished: number, setAside: number, width: number): [number, number, number] => {
168 const cells = (n: number) => (total > 0 ? Math.round((Math.min(n, total) / total) * width) : 0)
169 const done = cells(finished)
170 const aside = Math.max(0, cells(finished + setAside) - done)
171 return [done, aside, width - done - aside]
172}
173
174/**
175 * One run as one row of the band, for when several share it. It never wraps:
176 * the left part shrinks and cuts with an ellipsis, cost and link stay put on
177 * the right. Of the stages it names only the one at work, so rows of runs with
178 * different stages still line up.
179 */
180const bandRow = (el: El, run: RunView, step: number) => {
181 const { Box, Text, Link } = el
182 const color = RUN_COLOR(run)
183 const isShort = run.isTerminal && run.status !== 'completed'
184 const finished = run.stages[run.stages.length - 1]?.completed ?? 0
185 const failed = run.stages.reduce((sum, s) => sum + s.failed, 0)
186 const setAside = isShort ? 0 : Math.max(0, leadsThrough(run) - finished - failed)
187 const [doneCells, asideCells, restCells] = barCells(run.leadTotal, finished, setAside, 10)
188 const inFlight = (s: StageView) => (run.isTerminal ? 0 : (s.pending || 0) + (s.running || 0))
189 // The stage at work: the furthest one with jobs in flight, else the last one begun.
190 const working = [...run.stages].reverse().find(s => inFlight(s) > 0)
191 const shown = working ?? [...run.stages].reverse().find(s => s.total > 0) ?? run.stages[0]
192 const state = run.isTerminal
193 ? (RUN_STATUS_LABEL[run.status] ?? run.status)
194 : `${finished} fertig${setAside > 0 ? ` · ${setAside} raus` : ''}`
195 const stage = shown === undefined || run.isTerminal ? '' : `${STAGE_SHORT[shown.stage] ?? shown.stage} ${shown.completed}/${shown.total}`
196
197 return (
198 <Box key={`run-${run.id}`} flexDirection="column">
199 <Box>
200 <Box flexGrow={1} flexShrink={1} gap={1}>
201 <Text bold color={working !== undefined ? BRAND.accent : color}>
202 {working !== undefined ? SPIN[step % SPIN.length] : run.isTerminal ? '■' : '▶'}
203 </Text>
204 <Box width={9} flexShrink={0}><Text>{`${run.leadTotal} Leads`}</Text></Box>
205 <Box flexShrink={0}>
206 <Text color={color}>{'█'.repeat(doneCells)}</Text>
207 <Text dimColor>{'█'.repeat(asideCells)}</Text>
208 <Text color={color} dimColor>{'░'.repeat(restCells)}</Text>
209 </Box>
210 <Text bold color={run.isTerminal ? color : undefined} wrap="truncate-end">{state}</Text>
211 {stage !== '' && <Text dimColor wrap="truncate-end">{`· ${stage}`}</Text>}
212 {failed > 0 && <Text color={BRAND.error} wrap="truncate-end">{`· ${failed} Fehler`}</Text>}
213 </Box>
214 <Box flexShrink={0} gap={2} marginLeft={1}>
215 <Text dimColor>{`${run.spentUsd.toFixed(2)} $`}</Text>
216 {(run.appUrl !== null || run.campaignId > 0) && <Link href={runHref(run)} label="Öffnen ↗" />}
217 </Box>
218 </Box>
219 {run.reason !== null && <Text color={BRAND.warn} wrap="truncate-end">{` ${run.reason}`}</Text>}
220 </Box>
221 )
222}
223
224/**
225 * One run as a funnel, a row per stage: each stage against the leads that reached it,
226 * so 10 qualified, 5 passed on, 5 in research reads as it happened. The stage at work
227 * turns a spinner; one already done shows a tick.
228 */
229const funnelRun = (el: El, run: RunView, step: number) => {
230 const { Box, Text, Link } = el
231 const color = RUN_COLOR(run)
232 const budget = run.budgetUsd === null ? '' : `/${run.budgetUsd.toFixed(2)}`
233 const head = run.isTerminal ? (RUN_STATUS_LABEL[run.status] ?? run.status) : `${run.leadTotal} Leads`
234
235 return (
236 <Box key={`funnel-${run.id}`} flexDirection="column">
237 <Box justifyContent="space-between">
238 <Box gap={1}>
239 <Text bold>Outreach · Lead-Run</Text>
240 <Text color={color}>{`· ${head}`}</Text>
241 </Box>
242 <Box gap={2}>
243 <Text dimColor>{`${run.spentUsd.toFixed(2)}${budget} $`}</Text>
244 {(run.appUrl !== null || run.campaignId > 0) && <Link href={runHref(run)} label="Öffnen ↗" />}
245 </Box>
246 </Box>
247 {run.stages.map((s, i) => {
248 const prev = run.stages[i - 1]
249 const next = run.stages[i + 1]
250 // The first stage is reached by every lead once the run has prepared; later ones by those passed on.
251 const reached = i === 0 && s.total === 0 && run.status === 'preparing' ? run.leadTotal : s.total
252 // An ended run has nothing in flight, whatever its last counters still say.
253 const inFlight = (x: StageView) => (run.isTerminal ? 0 : (x.pending || 0) + (x.running || 0))
254 const live = inFlight(s)
255 const earlierLive = run.stages.slice(0, i).some(p => inFlight(p) > 0)
256 const isDone = reached > 0 && live === 0 && !earlierLive
257 const isSkipped = reached === 0 && (run.isTerminal || (prev !== undefined && !earlierLive && run.status !== 'preparing'))
258 const mark = live > 0 ? SPIN[step % SPIN.length] : isDone ? '✓' : isSkipped ? '–' : '·'
259 const markColor = live > 0 ? BRAND.accent : isDone ? BRAND.done : undefined
260 const out = next !== undefined && s.stage === 'qualification' && isDone ? Math.max(0, s.completed - next.total) : 0
261 const notes = [
262 out > 0 ? `${next !== undefined ? next.total : 0} passen · ${out} raus` : '',
263 live > 0 && s.running > 0 ? `${s.running} laufen` : '',
264 live > 0 && (s.pending || 0) > 0 ? `${s.pending} warten` : '',
265 isSkipped ? 'übersprungen' : '',
266 !isDone && !isSkipped && live === 0 ? 'wartet' : '',
267 ].filter(n => n !== '')
268
269 return (
270 <Box key={`funnel-${run.id}-${s.stage}`} gap={1}>
271 <Text color={markColor} dimColor={markColor === undefined}>{mark}</Text>
272 <Box width={15}><Text dimColor={!isDone && live === 0}>{STAGE_LABEL[s.stage] ?? s.stage}</Text></Box>
273 <Text color={isDone ? BRAND.done : BRAND.accent} dimColor={!isDone && live === 0}>{bar(s.completed, Math.max(1, reached), 10)}</Text>
274 <Box width={7}><Text>{reached > 0 ? `${s.completed}/${reached}` : '–'}</Text></Box>
275 <Text dimColor>{notes.join(' · ')}</Text>
276 {s.failed > 0 && <Text color={BRAND.error}>{`· ${s.failed} Fehler`}</Text>}
277 </Box>
278 )
279 })}
280 {run.reason !== null && <Text color={BRAND.warn} wrap="wrap">{run.reason}</Text>}
281 </Box>
282 )
283}
284
285/** The subscription windows now, in percent used; null where off a subscription. */
286type Usage = { session: number | null; week: number | null }
287
288const usageNow = async ($: EngineInterface): Promise<Usage> => {
289 try {
290 const { rateLimits } = await $.session.usage()
291 const of = (kind: string) => rateLimits.find(r => r.kind === kind)?.percentUsed ?? null
292 return { session: of('five_hour'), week: of('seven_day') }
293 } catch {
294 return { session: null, week: null }
295 }
296}
297
298/**
299 * What the run has used of the subscription windows: how far each rose since the run began, not
300 * where it stands. The engine reports whole percent, so a rise below one point reads as „< 1 %“.
301 * The windows count the whole account, so other sessions running at the same time show here too.
302 */
303const usageText = (group: LocalRun[], now: Usage): string | null => {
304 const part = (label: string, current: number | null, starts: (number | null)[]) => {
305 const known = starts.filter((v): v is number => v !== null)
306 if (current === null || known.length === 0) return null
307 const rose = Math.max(0, current - Math.min(...known))
308 return rose < 1 ? `${label} < 1 %` : `${label} ${Math.round(rose)} %`
309 }
310 const parts = [
311 part('5 h', now.session, group.map(l => l.sessionStartPercent)),
312 part('Woche', now.week, group.map(l => l.weekStartPercent)),
313 ].filter((v): v is string => v !== null)
314 return parts.length === 0 ? null : `verbraucht ${parts.join(' · ')}`
315}
316
317/** A local phase's campaign page: on the origin its server links to; null while that is unknown. */
318const localHref = (local: LocalRun, known: Record<string, string>) => {
319 const origin = local.server === '' ? undefined : known[local.server]
320 return origin === undefined ? null : appLink(origin, `/campaigns/${local.campaignId}/leads`)
321}
322
323const PHASE_SHORT: Record<string, string> = { qualification: 'Qual', research: 'Rech', email: 'Mail', verify: 'Prüf' }
324
325/** The phases of one campaign's run in the subscription, in the order they happen. */
326const groupPhases = (phases: LocalRun[]): LocalRun[][] => {
327 const byCampaign = new Map<number, LocalRun[]>()
328 for (const local of phases) byCampaign.set(local.campaignId, [...(byCampaign.get(local.campaignId) ?? []), local])
329 const order = (l: LocalRun) => PHASES.indexOf(l.phase as Phase)
330 return [...byCampaign.values()].map(g => [...g].sort((a, b) => order(a) - order(b)))
331}
332
333/**
334 * One run in the subscription as one row: its phases counted from the writes, so exact and without
335 * polling. Leads judged not qualified leave the later phases and count as handled, so a run that
336 * did all it could ends green, not as if work were missing.
337 */
338const localRow = (el: El, group: LocalRun[], href: string | null, usage: Usage) => {
339 const { Box, Text, Link } = el
340 const last = group[group.length - 1]
341 if (last === undefined) return null
342 const total = Math.max(...group.map(l => l.total))
343 const done = last.doneLeadIds.length
344 const skipped = last.skippedLeadIds.length
345 const isTerminal = group.every(l => l.isTerminal)
346 const complete = group.every(l => l.doneLeadIds.length + l.skippedLeadIds.length >= l.total)
347 const color = isTerminal ? (complete ? BRAND.done : BRAND.warn) : BRAND.local
348 const [doneCells, asideCells, restCells] = barCells(total, done, skipped, 14)
349 const verdicts = Object.values(last.verdicts ?? {})
350 const tally = (v: Verdict) => verdicts.filter(x => x === v).length
351 const state =
352 last.phase === 'verify'
353 ? `${done} geprüft` +
354 [['frei', tally('freigeben')], ['abgelehnt', tally('ablehnen')], ['Hinweis', tally('hinweis')]]
355 .filter(([, n]) => Number(n) > 0)
356 .map(([label, n]) => ` · ${n} ${label}`)
357 .join('')
358 : `${done} fertig${skipped > 0 ? ` · ${skipped} aussortiert` : ''}`
359 const stages = group
360 .map(l => `${PHASE_SHORT[l.phase] ?? l.phase} ${l.doneLeadIds.length}/${l.total - l.skippedLeadIds.length}`)
361 .join(' · ')
362 const used = usageText(group, usage)
363
364 return (
365 <Box key={`local-${last.campaignId}`}>
366 <Box flexGrow={1} flexShrink={1} gap={1}>
367 <Text bold color={color}>{isTerminal ? '■' : '▶'}</Text>
368 <Box width={9} flexShrink={0}><Text>{`${total} Leads`}</Text></Box>
369 <Box flexShrink={0}>
370 <Text color={color}>{'█'.repeat(doneCells)}</Text>
371 <Text dimColor>{'█'.repeat(asideCells)}</Text>
372 <Text color={color} dimColor>{'░'.repeat(restCells)}</Text>
373 </Box>
374 <Text bold color={isTerminal ? color : undefined} wrap="truncate-end">{state}</Text>
375 <Text dimColor wrap="truncate-end">{`· ${stages} · im Abo`}</Text>
376 </Box>
377 <Box flexShrink={0} gap={2} marginLeft={1}>
378 {used !== null && <Text dimColor>{used}</Text>}
379 {href !== null && <Link href={href} label="Öffnen ↗" />}
380 </Box>
381 </Box>
382 )
383}
384
385/**
386 * One import as one row, lined up with the run rows. No bar: a bulk import
387 * reports no share while it works, only its state and in the end its result.
388 */
389const importRow = (el: El, job: ImportView, step: number) => {
390 const { Box, Text, Link } = el
391 const color = !job.isTerminal ? BRAND.accent : job.status === 'completed' ? BRAND.done : BRAND.error
392
393 return (
394 <Box key={`import-${job.id}`}>
395 <Box flexGrow={1} flexShrink={1} gap={1}>
396 <Text bold color={color}>{job.isTerminal ? '■' : SPIN[step % SPIN.length]}</Text>
397 <Box width={9} flexShrink={0}><Text>{`${job.received} Leads`}</Text></Box>
398 <Box width={10} flexShrink={0}><Text>Import</Text></Box>
399 <Text bold={job.isTerminal} color={job.isTerminal ? color : undefined} wrap="truncate-end">{importOutcome(job)}</Text>
400 </Box>
401 <Box flexShrink={0} marginLeft={1}>
402 <Link href={importHref(job)} label="Öffnen ↗" />
403 </Box>
404 </Box>
405 )
406}
407
408/** The band folded to one line: how many runs, how many still going; `/outreach-status auf` opens it. */
409const foldedBand = (el: El, shown: RunView[], jobs: ImportView[], phases: LocalRun[]) => {
410 const { Box, Text } = el
411 const groups = groupPhases(phases)
412 const count = shown.length + jobs.length + groups.length
413 const active =
414 shown.filter(r => !r.isTerminal).length +
415 jobs.filter(j => !j.isTerminal).length +
416 groups.filter(g => g.some(l => !l.isTerminal)).length
417 const color = active > 0 ? BRAND.accent : BRAND.done
418
419 return (
420 <Box borderStyle="round" borderColor={color} paddingX={1} justifyContent="space-between">
421 <Text bold>{`Outreach · ${count} ${count === 1 ? 'Lauf' : 'Läufe'} · ${active === 0 ? 'alle beendet' : `${active} ${active === 1 ? 'läuft' : 'laufen'}`}`}</Text>
422 <Text dimColor>eingeklappt · /outreach-status auf</Text>
423 </Box>
424 )
425}
426
427/** Server runs, imports and local phases in one frame. A single run alone shows as a funnel; else a row each. */
428const band = (
429 el: El,
430 shown: RunView[],
431 jobs: ImportView[],
432 phases: LocalRun[],
433 step: number,
434 known: Record<string, string>,
435 usage: Usage,
436) => {
437 const { Box, Text } = el
438 const groups = groupPhases(phases)
439 const count = shown.length + jobs.length + groups.length
440 const active =
441 shown.filter(r => !r.isTerminal).length +
442 jobs.filter(j => !j.isTerminal).length +
443 groups.filter(g => g.some(l => !l.isTerminal)).length
444 const allGood =
445 shown.every(r => r.status === 'completed') &&
446 jobs.every(j => j.status === 'completed') &&
447 phases.every(l => l.doneLeadIds.length + l.skippedLeadIds.length >= l.total)
448 const color = active > 0 ? BRAND.accent : allGood ? BRAND.done : BRAND.warn
449 const single = shown.length === 1 && jobs.length === 0 && groups.length === 0 ? shown[0] : undefined
450
451 if (single !== undefined) {
452 return (
453 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
454 {funnelRun(el, single, step)}
455 </Box>
456 )
457 }
458
459 const summary = active === 0 ? 'alle beendet' : active === 1 ? '1 läuft' : `${active} laufen`
460 const title = jobs.length === count ? (count === 1 ? 'Import' : `${count} Importe`) : `${count} Läufe`
461
462 return (
463 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
464 <Box justifyContent="space-between">
465 <Text bold>{`Outreach · ${title}`}</Text>
466 <Text color={color}>{summary}</Text>
467 </Box>
468 {shown.map(run => bandRow(el, run, step))}
469 {jobs.map(job => importRow(el, job, step))}
470 {groups.map(g => localRow(el, g, g.map(l => localHref(l, known)).find(h => h !== null) ?? null, usage))}
471 </Box>
472 )
473}
474
475/** How many runs that have ended stay in the band beside the ones still going. */
476const ENDED_SHOWN = 2
477
478/**
479 * Every run still going, oldest first, then the few that ended last. No cap on
480 * the active ones: a run started last still belongs in the band, with 0 % too.
481 * (list_lead_runs answers newest first, so insertion order is no start order.)
482 */
483const bandRuns = (all: RunView[]): RunView[] => [
484 ...all.filter(r => !r.isTerminal).sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0)),
485 ...all
486 .filter(r => r.isTerminal)
487 .sort((a, b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0))
488 .slice(0, ENDED_SHOWN),
489]
490
491const bandImports = (all: ImportView[]): ImportView[] => [
492 ...all.filter(j => !j.isTerminal),
493 ...all
494 .filter(j => j.isTerminal)
495 .sort((a, b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0))
496 .slice(0, ENDED_SHOWN),
497]
498
499/** A run in the subscription has up to three phases; those of the last two runs stay in view once ended. */
500const ENDED_PHASES_SHOWN = 6
501
502const bandPhases = (all: LocalRun[]): LocalRun[] => [
503 ...all.filter(l => !l.isTerminal),
504 ...all
505 .filter(l => l.isTerminal)
506 .sort((a, b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0))
507 .slice(0, ENDED_PHASES_SHOWN),
508]
509
510// ── the runs of this chat ────────────────────────────────────────────────
511
512const call = async ($: EngineInterface, server: string, tool: string, args: Record<string, unknown>) => {
513 const res = await $.mcp.call(server, tool, args)
514 return res.isError ? null : payloadOf(res.structuredContent ?? res.content)
515}
516
517/** Keeps a run's newest state; says so in a toast when it has just ended. */
518const putRun = async ($: EngineInterface, payload: Record<string, unknown>, server: string) => {
519 let ended: RunView | null = null
520 await update($, runs, all => {
521 const id = String(payload.id ?? payload.lead_run_id ?? '')
522 const before = all[id]
523 const next = runFrom(payload, before)
524 if (next === null) return all
525 next.server = server
526 if (next.isTerminal && next.finishedAt === null) next.finishedAt = Date.now()
527 if (next.isTerminal && before !== undefined && !before.isTerminal) ended = next
528 return { ...all, [next.id]: next }
529 })
530 if (ended !== null) {
531 const run: RunView = ended
532 $.ui.toast(`Outreach: Lead-Run ${RUN_STATUS_LABEL[run.status] ?? run.status}`)
533 }
534}
535
536/** Keeps an import's newest state; says what it came to in a toast when it has just ended. */
537const putImport = async ($: EngineInterface, payload: Record<string, unknown>, server: string): Promise<boolean> => {
538 let ended: ImportView | null = null
539 let kept = false
540 await update($, imports, all => {
541 const before = all[String(payload.job_id ?? '')]
542 const next = importFrom(payload, before)
543 if (next === null) return all
544 kept = true
545 next.server = server
546 if (next.isTerminal && next.finishedAt === null) next.finishedAt = Date.now()
547 if (next.isTerminal && before !== undefined && !before.isTerminal) ended = next
548 return { ...all, [next.id]: next }
549 })
550 if (ended !== null) {
551 const job: ImportView = ended
552 $.ui.toast(`Outreach: Import ${importOutcome(job)}`)
553 }
554 return kept
555}
556
557const isGoing = async ($: EngineInterface) =>
558 Object.values(await read($, runs)).some(r => !r.isTerminal) || Object.values(await read($, imports)).some(j => !j.isTerminal)
559
560/** Asks for each run and import still going; stops asking once none is. Ended ones leave the band after LINGER_MS. */
561const tick = async ($: EngineInterface) => {
562 for (const job of Object.values(await read($, imports))) {
563 if (job.isTerminal) continue
564 try {
565 const server = job.server || configuredServer
566 const status = await call($, server, 'get_job_status', { job_id: job.id })
567 if (status) await putImport($, status, server)
568 } catch {
569 // Not connected right now: the next tick tries again.
570 }
571 }
572 // One list_lead_runs per server answers for all its runs at once; only a run that list leaves
573 // out (older than its newest 20) is asked for on its own. Fewer calls keep a busy server quiet.
574 const going = Object.values(await read($, runs)).filter(r => !r.isTerminal)
575 const byServer = new Map<string, RunView[]>()
576 for (const run of going) {
577 const server = run.server || configuredServer
578 byServer.set(server, [...(byServer.get(server) ?? []), run])
579 }
580 for (const [server, serverRuns] of byServer) {
581 const listed = new Set<string>()
582 const answered = new Set<string>()
583 try {
584 const answer = await call($, server, 'list_lead_runs', {})
585 for (const one of Array.isArray(answer?.runs) ? answer.runs : []) {
586 if (typeof one !== 'object' || one === null) continue
587 const row = one as Record<string, unknown>
588 const id = String(row.id ?? '')
589 if (!serverRuns.some(r => r.id === id)) continue
590 listed.add(id)
591 answered.add(id)
592 await putRun($, row, server)
593 }
594 } catch {
595 // Not connected right now: the next tick tries again.
596 }
597 for (const run of serverRuns) {
598 if (listed.has(run.id)) continue
599 try {
600 const status = await call($, server, 'get_lead_run_status', { lead_run_id: run.id })
601 if (status && String(status.id ?? status.lead_run_id ?? '') === run.id) {
602 answered.add(run.id)
603 await putRun($, status, server)
604 }
605 } catch {
606 // Not connected right now: the next tick tries again.
607 }
608 }
609 // A slow server answers through a background task the band never sees: without a check the band
610 // would ask forever and never learn the run ended. After MAX_MISSES rounds it stops asking.
611 for (const run of serverRuns) {
612 const misses = answered.has(run.id) ? 0 : (missedRounds.get(run.id) ?? 0) + 1
613 missedRounds.set(run.id, misses)
614 if (misses < MAX_MISSES) continue
615 await update($, runs, all => {
616 const current = all[run.id]
617 if (current === undefined || current.isTerminal) return all
618 const stale: RunView = {
619 ...current,
620 status: 'stand_unbekannt',
621 isTerminal: true,
622 finishedAt: Date.now(),
623 reason: 'Server antwortet nicht rechtzeitig – /outreach-status lädt den Stand neu.',
624 }
625 return { ...all, [run.id]: stale }
626 })
627 }
628 }
629 const now = Date.now()
630 const stays = (finishedAt: number | null) => finishedAt === null || now - finishedAt < LINGER_MS
631 await update($, runs, all => Object.fromEntries(Object.entries(all).filter(([, r]) => stays(r.finishedAt))))
632 await update($, imports, all => Object.fromEntries(Object.entries(all).filter(([, j]) => stays(j.finishedAt))))
633 const left = [...Object.values(await read($, runs)), ...Object.values(await read($, imports))]
634 if (!left.some(r => !r.isTerminal)) {
635 poller?.cancel()
636 poller = null
637 // One last pass once the ended ones are due to leave.
638 if (left.length > 0) $.clock.after(LINGER_MS, () => void tick($))
639 }
640}
641
642// ── workflow phases Claude runs itself ───────────────────────────────────
643
644/** Keeps the app origin a server's links point to, so a local phase links to the right app. */
645const learnOrigin = async ($: EngineInterface, server: string, payload: Record<string, unknown> | null) => {
646 if (payload === null) return
647 const campaign = payload.campaign
648 const lead = Array.isArray(payload.leads) ? payload.leads[0] : payload.lead
649 const link = [payload.appUrl, isRecord(campaign) ? campaign.appUrl : null, isRecord(lead) ? lead.appUrl : null]
650 .find((v): v is string => typeof v === 'string' && /^https?:\/\//.test(v))
651 if (link === undefined) return
652 const origin = new URL(link).origin
653 await update($, origins, all => (all[server] === origin ? all : { ...all, [server]: origin }))
654}
655
656const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
657
658
659const PHASES = ['qualification', 'research', 'email', 'verify'] as const
660type Phase = (typeof PHASES)[number]
661
662const localKey = (campaignId: number, phase: Phase) => `${campaignId}:${phase}`
663
664/** Which phase a successful write belongs to: email variables, a verdict, or research. */
665const phaseOfWrite = (name: string | null, input: Record<string, unknown>): Phase | null => {
666 if (name === 'save_lead_variables') return 'email'
667 if (name === 'approve_lead_variables' || name === 'reject_lead_variables') return 'verify'
668 if (name !== 'write_lead_details' || input.dry_run === true) return null
669 const fields = typeof input.fields === 'object' && input.fields !== null ? Object.keys(input.fields) : []
670 return fields.some(f => f.startsWith('qualification')) ? 'qualification' : 'research'
671}
672
673/** Done once every lead of the phase is written or has left the chain. */
674const withLead = (local: LocalRun, leadId: number, how: 'done' | 'skipped'): LocalRun => {
675 const doneLeadIds = how === 'done' ? [...local.doneLeadIds, leadId] : local.doneLeadIds
676 const skippedLeadIds = how === 'skipped' ? [...local.skippedLeadIds, leadId] : local.skippedLeadIds
677 const isTerminal = doneLeadIds.length + skippedLeadIds.length >= local.total
678 return { ...local, doneLeadIds, skippedLeadIds, isTerminal, finishedAt: isTerminal ? Date.now() : null }
679}
680
681/**
682 * Counts one lead as done for its phase. A lead judged not qualified also leaves the research and
683 * email phases of its campaign still going: they will never write it, and must not wait for it.
684 */
685const countWrite = async ($: EngineInterface, phase: Phase, input: Record<string, unknown>, server: string) => {
686 const campaignId = Number(input.campaign_id)
687 const leadId = Number(input.lead_id)
688 if (!Number.isFinite(campaignId) || !Number.isFinite(leadId)) return
689 const fields = typeof input.fields === 'object' && input.fields !== null ? (input.fields as Record<string, unknown>) : {}
690 const leaves = phase === 'qualification' && fields.qualificationFitLevel === 'not_qualified'
691 const ended: LocalRun[] = []
692 await update($, locals, all => {
693 const next = { ...all }
694 const touch = (key: string, how: 'done' | 'skipped') => {
695 const local = next[key]
696 if (local === undefined || local.isTerminal) return
697 if (local.doneLeadIds.includes(leadId) || local.skippedLeadIds.includes(leadId)) return
698 const changed = withLead({ ...local, server: local.server || server }, leadId, how)
699 if (changed.isTerminal) ended.push(changed)
700 next[key] = changed
701 }
702 touch(localKey(campaignId, phase), 'done')
703 // Mail written without a research write: the lead's research already existed (it holds across campaigns).
704 if (phase === 'email') touch(localKey(campaignId, 'research'), 'done')
705 if (leaves) {
706 touch(localKey(campaignId, 'research'), 'skipped')
707 touch(localKey(campaignId, 'email'), 'skipped')
708 }
709 return next
710 })
711 for (const local of ended) await endLocal($, local)
712}
713
714const endLocal = async ($: EngineInterface, local: LocalRun) => {
715 const skipped = local.skippedLeadIds.length
716 $.ui.toast(
717 `Outreach: ${STAGE_LABEL[local.phase] ?? local.phase} fertig · ${local.doneLeadIds.length}/${local.total} Leads` +
718 (skipped > 0 ? `, ${skipped} aussortiert` : ''),
719 )
720 $.clock.after(LINGER_MS, () =>
721 void update($, locals, all => {
722 const now = Date.now()
723 return Object.fromEntries(Object.entries(all).filter(([, l]) => l.finishedAt === null || now - l.finishedAt < LINGER_MS))
724 }),
725 )
726}
727
728type ProgressInput = {
729 campaign_id?: unknown
730 phase?: unknown
731 total?: unknown
732 action?: unknown
733 lead_id?: unknown
734 outcome?: unknown
735}
736
737const VERDICTS: readonly Verdict[] = ['freigeben', 'ablehnen', 'hinweis']
738
739/** A verify agent's verdict for one lead: counts the lead as checked and keeps the outcome for the row. */
740const recordVerdict = async ($: EngineInterface, input: ProgressInput): Promise<string> => {
741 const campaignId = Number(input.campaign_id)
742 const leadId = Number(input.lead_id)
743 const outcome = VERDICTS.find(v => v === input.outcome)
744 if (!Number.isFinite(campaignId) || !Number.isFinite(leadId) || outcome === undefined) {
745 return 'campaign_id, lead_id und outcome (freigeben|ablehnen|hinweis) angeben.'
746 }
747 const key = localKey(campaignId, 'verify')
748 let ended: LocalRun | null = null
749 await update($, locals, all => {
750 const local = all[key]
751 if (local === undefined || local.isTerminal) return all
752 const verdicts = { ...(local.verdicts ?? {}), [String(leadId)]: outcome }
753 const counted = local.doneLeadIds.includes(leadId) ? { ...local, verdicts } : withLead({ ...local, verdicts }, leadId, 'done')
754 if (counted.isTerminal) ended = counted
755 return { ...all, [key]: counted }
756 })
757 if (ended !== null) await endLocal($, ended)
758 return `Urteil gezählt: Lead ${leadId} ${outcome}.`
759}
760
761/** What the workflow skills call: a phase begins with its lead count, or ends. */
762const reportProgress = async ($: EngineInterface, input: ProgressInput): Promise<string> => {
763 if (input.action === 'verdict') return recordVerdict($, input)
764 const campaignId = Number(input.campaign_id)
765 const phase = PHASES.find(p => p === input.phase)
766 if (!Number.isFinite(campaignId) || phase === undefined) return 'campaign_id und phase (qualification|research|email|verify) angeben.'
767 const key = localKey(campaignId, phase)
768 if (input.action === 'end') {
769 const local = (await read($, locals))[key]
770 if (local === undefined || local.isTerminal) return 'Keine laufende Phase dazu.'
771 const ended = { ...local, isTerminal: true, finishedAt: Date.now() }
772 // An end before every lead is through: what remains was neither done nor skipped.
773 await update($, locals, all => ({ ...all, [key]: ended }))
774 await endLocal($, ended)
775 return `Phase beendet: ${ended.doneLeadIds.length}/${ended.total} Leads.`
776 }
777 // A phase already going keeps its count: a skill that starts it again for its next batch must not reset it.
778 const going = (await read($, locals))[key]
779 if (going !== undefined && !going.isTerminal) {
780 return `Phase läuft bereits: ${STAGE_LABEL[phase]} · ${going.doneLeadIds.length}/${going.total} Leads.`
781 }
782 const total = Math.max(1, Math.floor(Number(input.total)) || 1)
783 const local: LocalRun = {
784 id: key,
785 campaignId,
786 phase,
787 total,
788 doneLeadIds: [],
789 skippedLeadIds: [],
790 isTerminal: false,
791 finishedAt: null,
792 server: '',
793 ...(await usageNow($).then(u => ({ weekStartPercent: u.week, sessionStartPercent: u.session }))),
794 }
795 await update($, locals, all => ({ ...all, [key]: local }))
796 spin($)
797 return `Fortschritt läuft im Band: ${STAGE_LABEL[phase]} · 0/${total} Leads. Gezählt wird jeder erfolgreiche Schreibaufruf.`
798}
799
800/** The runs still going from a list_lead_runs answer join the band; ended ones are history and stay out. */
801const putActiveRuns = async ($: EngineInterface, payload: Record<string, unknown>, server: string): Promise<number> => {
802 const listed = Array.isArray(payload.runs) ? payload.runs : []
803 let active = 0
804 for (const run of listed) {
805 if (typeof run !== 'object' || run === null) continue
806 const one = run as Record<string, unknown>
807 if (one.is_terminal === true) continue
808 await putRun($, one, server)
809 active += 1
810 }
811 if (active > 0) watch($)
812 return active
813}
814
815const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
816const SPIN_MS = 250
817
818/** Turns the band's spinner while anything runs, and stops itself once nothing does. */
819const spin = ($: EngineInterface) => {
820 if (spinner !== null) return
821 spinner = $.clock.every(SPIN_MS, () =>
822 void (async () => {
823 const going = (await isGoing($)) || Object.values(await read($, locals)).some(l => !l.isTerminal)
824 if (!going) {
825 spinner?.cancel()
826 spinner = null
827 return
828 }
829 await update($, frame, n => (n + 1) % SPIN.length)
830 })(),
831 )
832}
833
834const watch = ($: EngineInterface) => {
835 if (poller === null) poller = $.clock.every(POLL_MS, () => void tick($))
836 spin($)
837}
838
839const importHref = (job: ImportView) =>
840 job.appUrl ?? appLink(base, job.campaignId ? `/campaigns/${job.campaignId}/leads` : '/jobs')
841
842/**
843 * In the chat an import is one line, several started together one line between
844 * them; how it goes lives in the band. A status check that finds it ended
845 * says what it came to.
846 */
847const importLine = (el: El, jobs: ImportView[]) => {
848 const { Box, Text, Link } = el
849 const first = jobs[0]
850 if (first === undefined) return null
851 const leads = jobs.reduce((sum, j) => sum + j.received, 0)
852 const sameCampaign = jobs.every(j => j.campaignId === first.campaignId)
853 const href = sameCampaign ? importHref(first) : appLink(base, '/jobs')
854 if (jobs.length === 1 && first.isTerminal) {
855 const color = first.status === 'completed' ? BRAND.done : BRAND.error
856 return (
857 <Box gap={1}>
858 <Text color={color}>■</Text>
859 <Text>{`Import · ${importOutcome(first)}`}</Text>
860 <Link href={href} label="In der App öffnen ↗" />
861 </Box>
862 )
863 }
864 const what = jobs.length === 1 ? 'Import' : `${jobs.length} Importe`
865 const state = jobs.length === 1 && first.status === 'processing' ? 'läuft' : 'gestartet'
866
867 return (
868 <Box gap={1}>
869 <Text color={BRAND.accent}>▶</Text>
870 <Text>{`${what} ${state} · ${leads} Leads`}</Text>
871 <Text dimColor>· Fortschritt über dem Prompt</Text>
872 <Link href={href} label="In der App öffnen ↗" />
873 </Box>
874 )
875}
876
877/** The card for one outreach call, or null when it is not ours or carries nothing to draw. */
878const cardFor = (el: El, name: string | null, output: unknown, key: string) => {
879 if (name === null) return null
880 const payload = payloadOf(output)
881 if (payload === null) return null
882 const { Box } = el
883 let card = null
884 if (name === 'list_leads' || name === 'search_leads' || name === 'get_lead_data') {
885 const list = leadsFrom(payload)
886 card = list && list.leads.length > 0 ? leadPreview(el, list) : null
887 } else if (name === 'start_lead_run') {
888 const run = runFrom(payload)
889 card = run ? startLine(el, run) : null
890 } else if (name === 'import_leads' || name === 'get_job_status') {
891 const job = importFrom(payload)
892 card = job ? importLine(el, [job]) : null
893 }
894 return card === null ? null : <Box key={`outreach-${key}`}>{card}</Box>
895}
896
897/** The progress tool skills call in manual mode; may fail where a server already carries this plugin's name. */
898const registerProgressTool = async ($: EngineInterface) => {
899 await $.tool.register({
900 name: 'outreach_progress',
901 description:
902 'Zeigt den Fortschritt eines Abo-Laufs (Leads, die du selbst mit Subagents bearbeitest: outreach-pipeline --abo, ' +
903 'outreach-qualify, outreach-research, outreach-generate) als eine Zeile im Outreach-Band über dem Prompt. Zu Beginn jeder Phase ' +
904 'einmal mit action=start, campaign_id, phase (qualification|research|email|verify) und total (Anzahl Leads der Phase) aufrufen. ' +
905 'Gezählt wird danach automatisch: jeder erfolgreiche write_lead_details- bzw. save_lead_variables-Aufruf für einen ' +
906 'Lead dieser Kampagne zählt als erledigt, in der Phase verify jedes approve/reject. Ein Prüf-Agent meldet sein Urteil ' +
907 'mit action=verdict, campaign_id, lead_id und outcome (freigeben|ablehnen|hinweis), auch wenn er nichts schreibt. ' +
908 'action=end schließt die Phase vorzeitig ab. Nicht für Server-Läufe (start_lead_run).',
909 inputSchema: {
910 type: 'object',
911 properties: {
912 action: { type: 'string', enum: ['start', 'end', 'verdict'] },
913 campaign_id: { type: 'integer' },
914 phase: { type: 'string', enum: ['qualification', 'research', 'email', 'verify'] },
915 total: { type: 'integer', minimum: 1 },
916 lead_id: { type: 'integer' },
917 outcome: { type: 'string', enum: ['freigeben', 'ablehnen', 'hinweis'] },
918 },
919 required: ['campaign_id'],
920 },
921 })
922}
923
924/** /outreach-status (old name /outreach-runs): every run still going appears in the band; „zu“ folds it, „auf“ opens it. */
925const statusCommand = async ($: EngineInterface, e: { args?: string }) => {
926 const arg = (e.args ?? '').trim().toLowerCase()
927 if (arg === 'zu' || arg === 'ein' || arg === 'einklappen') {
928 await update($, folded, () => true)
929 return { text: 'Outreach-Band eingeklappt – `/outreach-status auf` klappt es wieder auf.' }
930 }
931 await update($, folded, () => false)
932 let active = 0
933 let reached = 0
934 for (const server of await akquiseServers($)) {
935 try {
936 const listed = await call($, server, 'list_lead_runs', { active_only: true })
937 reached += 1
938 if (listed !== null) active += await putActiveRuns($, listed, server)
939 } catch {
940 // Not connected: the others may still answer.
941 }
942 }
943 if (reached === 0) return { text: 'Der Outreach-MCP ist nicht erreichbar – ist er verbunden?' }
944 return { text: active === 0 ? 'Gerade läuft kein Lead-Run.' : `${active} laufende(r) Lead-Run(s) – Fortschritt über dem Prompt.` }
945}
946
947export const register: Register = (on, options) => {
948 configuredServer = String(options.mcpServer ?? 'akquise').replace(/[^A-Za-z0-9_-]/g, '_')
949 knownServers = new Map()
950 poller = null
951 spinner = null
952 missedRounds.clear()
953 base = String(options.appUrl ?? 'https://outreach.akquise.de')
954
955 // A standalone tool row: our card in place of the engine's result block.
956 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
957 if (e.props.isErrored) return next(e)
958 const card = cardFor($.ui.resolve(e), (await recognize($, e.props.tool))?.name ?? null, e.props.output, e.props.tool_use_id)
959 return card ?? next(e)
960 })
961
962 // Lookups like list_leads are folded into one group line ("Called akquise …"):
963 // the engine's line stays, our cards follow beneath it.
964 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
965 const el = $.ui.resolve(e)
966 const { Box } = el
967 const cards = []
968 // Imports started in one go become one line, after the other cards.
969 const started: ImportView[] = []
970 for (const [i, c] of e.props.calls.entries()) {
971 if (c.isRunning || c.isErrored || c.isInterrupted) continue
972 const name = (await recognize($, c.tool))?.name ?? null
973 const payload = name === 'import_leads' ? payloadOf(c.output) : null
974 const job = payload === null ? null : importFrom(payload)
975 if (job !== null) {
976 started.push(job)
977 continue
978 }
979 const card = cardFor(el, name, c.output, c.tool_use_id ?? `call-${i}`)
980 if (card !== null) cards.push(card)
981 }
982 const startedLine = started.length > 0 ? importLine(el, started) : null
983 if (startedLine !== null) cards.push(<Box key="outreach-imports">{startedLine}</Box>)
984 if (cards.length === 0) return next(e)
985 const line = await next(e)
986
987 return (
988 <Box flexDirection="column">
989 {line}
990 {cards}
991 </Box>
992 )
993 })
994
995 // After a reload the timer is gone but the runs are kept: pick up the ones still going.
996 // Each part stands alone: one that fails (an MCP server already named like
997 // this plugin, an older engine) must not take the others down with it.
998 on('session.start', async ($, e, next) => {
999 try {
1000 await registerProgressTool($)
1001 } catch {
1002 // The band and /outreach-status work without the progress tool.
1003 }
1004 try {
1005 await $.command.register({
1006 name: 'outreach-status',
1007 description: 'Was gerade läuft (Server-Läufe, Abo-Läufe, Imports) über dem Prompt anzeigen; „zu“ klappt das Band auf eine Zeile ein, „auf“ wieder auf',
1008 })
1009 // The former name keeps working for habits and older notes.
1010 await $.command.register({ name: 'outreach-runs', description: 'Alter Name von /outreach-status' })
1011 } catch {
1012 // Without the command, runs still join the band when Claude checks them.
1013 }
1014 try {
1015 if (await isGoing($)) watch($)
1016 } catch {
1017 // Nothing to resume.
1018 }
1019 return next(e)
1020 })
1021
1022
1023 // One command, no wording needed: every run still going appears in the band.
1024 // „zu“ shrinks the band to one line, „auf“ (or the bare command) opens it again.
1025 on('command.run', { command: 'outreach-status' }, statusCommand)
1026 on('command.run', { command: 'outreach-runs' }, statusCommand)
1027
1028 on('tool.call', { tool: 'mcp__outreach__outreach_progress' }, async ($, e) => ({
1029 result: await reportProgress($, e as unknown as ProgressInput),
1030 }))
1031
1032 // A run Claude starts, checks or lists as running joins the band, and the band keeps it current.
1033 on('tool.call', async ($, e, next) => {
1034 const ran = await next(e)
1035 if (ran.deny !== undefined || ran.isError === true) return ran
1036 const recognized = await recognize($, e.tool)
1037 if (recognized === null) return ran
1038 const { name, server } = recognized
1039 await learnOrigin($, server, payloadOf(ran.result))
1040 const written = phaseOfWrite(name, e as unknown as Record<string, unknown>)
1041 if (written !== null) {
1042 await countWrite($, written, e as unknown as Record<string, unknown>, server)
1043 return ran
1044 }
1045 if (name === 'import_leads' || name === 'get_job_status') {
1046 const payload = payloadOf(ran.result)
1047 if (payload !== null && (await putImport($, payload, server))) watch($)
1048 return ran
1049 }
1050 if (name === 'list_lead_runs') {
1051 const listed = payloadOf(ran.result)
1052 if (listed !== null) await putActiveRuns($, listed, server)
1053 return ran
1054 }
1055 const isRun = name === 'start_lead_run' || name === 'get_lead_run_status' || name === 'cancel_lead_run'
1056 if (!isRun) return ran
1057 const payload = payloadOf(ran.result)
1058 if (payload !== null) {
1059 await putRun($, payload, server)
1060 watch($)
1061 }
1062 return ran
1063 })
1064
1065 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1066 const shown = bandRuns(Object.values(await read($, runs)))
1067 const jobs = bandImports(Object.values(await read($, imports)))
1068 const phases = bandPhases(Object.values(await read($, locals)))
1069 if (e.props.hasSurvey || shown.length + jobs.length + phases.length === 0) return next(e)
1070 if (await read($, folded)) return foldedBand($.ui.resolve(e), shown, jobs, phases)
1071 const usage = phases.length > 0 ? await usageNow($) : { session: null, week: null }
1072 return band($.ui.resolve(e), shown, jobs, phases, await read($, frame), await read($, origins), usage)
1073 })
1074}
1075hooks/model.ts 258 lines1import type { ImportView, RunView, StageView } from '../types'
2
3/** Die Farben des Bands (Akquise-Palette). */
4export const BRAND = {
5 /** primary 500, Grün: Rahmen, laufende Arbeit, Fortschritt. */
6 accent: '#36ae66',
7 /** primary 400: Erledigtes, damit es sich vom Laufenden abhebt. */
8 done: '#6fc68e',
9 /** primary 300: Phasen, die Claude lokal ausführt. */
10 local: '#a8dfbd',
11 warn: '#f59e0b',
12 error: '#ef4444',
13} as const
14
15type Obj = Record<string, unknown>
16
17const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
18const num = (v: unknown, fallback = 0): number => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
19const str = (v: unknown, fallback = ''): string => (typeof v === 'string' ? v : fallback)
20
21/**
22 * Pulls the JSON object out of whatever an MCP tool result looks like by the
23 * time it reaches us: structuredContent, content blocks, a JSON string or the
24 * object itself.
25 */
26export function payloadOf(v: unknown): Obj | null {
27 if (v === null || v === undefined) return null
28 if (typeof v === 'string') {
29 try {
30 const parsed: unknown = JSON.parse(v)
31 return isObj(parsed) ? parsed : null
32 } catch {
33 return null
34 }
35 }
36 if (Array.isArray(v)) {
37 for (const block of v) {
38 if (isObj(block) && block.type === 'text') {
39 const found = payloadOf(block.text)
40 if (found) return found
41 }
42 }
43 return null
44 }
45 if (!isObj(v)) return null
46 if (isObj(v.structuredContent)) return v.structuredContent
47 if ('content' in v && !('leads' in v)) return payloadOf(v.content)
48 return v
49}
50
51export const TERMINAL_RUN = new Set([
52 'completed',
53 'completed_with_failures',
54 'cancelled',
55 'budget_exhausted',
56 'limit_exhausted',
57 'provider_exhausted',
58 'failed',
59])
60
61export const RUN_STATUS_LABEL: Record<string, string> = {
62 preparing: 'wird vorbereitet',
63 running: 'läuft',
64 completed: 'fertig',
65 completed_with_failures: 'fertig, mit Fehlern',
66 cancelled: 'abgebrochen',
67 budget_exhausted: 'Budget aufgebraucht',
68 limit_exhausted: 'Planlimit erreicht',
69 provider_exhausted: 'KI-Anbieter nicht verfügbar',
70 failed: 'fehlgeschlagen',
71 stand_unbekannt: 'Stand unbekannt',
72}
73
74export const STAGE_LABEL: Record<string, string> = {
75 qualification: 'Qualifizierung',
76 research: 'Recherche',
77 email: 'E-Mail',
78 verify: 'Prüfung',
79}
80
81export const FIT_LABEL: Record<string, { text: string; color: string }> = {
82 highly_qualified: { text: 'sehr passend', color: BRAND.done },
83 qualified: { text: 'passend', color: BRAND.done },
84 mid_qualified: { text: 'bedingt', color: BRAND.warn },
85 not_qualified: { text: 'passt nicht', color: BRAND.error },
86}
87
88/** A run from get_lead_run_status / list_lead_runs (camelCase) or start_lead_run (snake_case). */
89export function runFrom(p: Obj, previous?: RunView): RunView | null {
90 const id = str(p.id) || str(p.lead_run_id)
91 if (id === '') return null
92 const status = str(p.status === 'success' ? p.run_status : p.status, previous?.status ?? 'preparing')
93 const stageNames = Array.isArray(p.stages) ? p.stages.filter((s): s is string => typeof s === 'string') : []
94 const progress = Array.isArray(p.stageProgress) ? p.stageProgress.filter(isObj) : []
95 const stages: StageView[] = (stageNames.length > 0 ? stageNames : (previous?.stages.map(s => s.stage) ?? [])).map(stage => {
96 const row = progress.find(r => r.stage === stage)
97 const before = previous?.stages.find(s => s.stage === stage)
98 return {
99 stage,
100 total: num(row?.total, before?.total ?? 0),
101 pending: num(row?.pending, before?.pending ?? 0),
102 running: num(row?.running, before?.running ?? 0),
103 completed: num(row?.completed, before?.completed ?? 0),
104 failed: row ? num(row.failed) + num(row.noResult) + num(row.cancelled) : (before?.failed ?? 0),
105 }
106 })
107 const isTerminal = p.is_terminal === true || TERMINAL_RUN.has(status)
108
109 return {
110 id,
111 campaignId: num(p.campaignId ?? p.campaign_id, previous?.campaignId ?? 0),
112 status,
113 stages,
114 leadTotal: num(p.leadTotal ?? p.lead_total, previous?.leadTotal ?? 0),
115 spentUsd: num(p.spentUsd, previous?.spentUsd ?? 0),
116 budgetUsd: typeof (p.budgetUsd ?? p.budget_usd) === 'number' ? num(p.budgetUsd ?? p.budget_usd) : (previous?.budgetUsd ?? null),
117 isTerminal,
118 isDemo: previous?.isDemo ?? false,
119 finishedAt: previous?.finishedAt ?? null,
120 server: previous?.server ?? '',
121 createdAt: previous?.createdAt ?? (Date.parse(str(p.createdAt)) || Date.now()),
122 appUrl: urlOf(p.appUrl) ?? previous?.appUrl ?? null,
123 reason: isTerminal && status !== 'completed' ? str(p.statusReason) || str(p.status_description) || null : null,
124 }
125}
126
127export const IMPORT_STATUS_LABEL: Record<string, string> = {
128 pending: 'wartet',
129 processing: 'läuft',
130 completed: 'fertig',
131 failed: 'fehlgeschlagen',
132 cancelled: 'abgebrochen',
133}
134
135/**
136 * An import from import_leads (queued) or get_job_status (status, result).
137 * get_job_status answers for every kind of job: one of another type is no
138 * import, unless it is one already known.
139 */
140export function importFrom(p: Obj, previous?: ImportView): ImportView | null {
141 const id = str(p.job_id)
142 if (id === '') return null
143 if (previous === undefined && typeof p.type === 'string' && p.type !== 'lead_bulk_import') return null
144 const execution = isObj(p.execution) ? p.execution : null
145 const result = isObj(p.result) ? p.result : null
146 const status = p.status === 'success' && p.queued === true ? 'pending' : str(p.status, previous?.status ?? 'pending')
147 const isTerminal = ['completed', 'failed', 'cancelled'].includes(status)
148 const failure = status === 'failed' ? str(p.error) || str(result?.message) || str(execution?.failure_reason) : ''
149
150 return {
151 id,
152 status,
153 percent: status === 'completed' ? 100 : num(execution?.progress_percent, previous?.percent ?? 0),
154 message: failure || str(execution?.progress_message, previous?.message ?? ''),
155 received: num(p.received, previous?.received ?? num(result?.total)),
156 campaignId: typeof p.campaign_id === 'number' ? p.campaign_id : (previous?.campaignId ?? null),
157 imported: typeof result?.imported === 'number' ? result.imported : (previous?.imported ?? null),
158 // The server lists the duplicate rows; older answers gave a count.
159 duplicates: Array.isArray(result?.duplicates)
160 ? result.duplicates.length
161 : typeof result?.duplicates === 'number' ? result.duplicates : (previous?.duplicates ?? null),
162 isTerminal,
163 isDemo: previous?.isDemo ?? false,
164 finishedAt: previous?.finishedAt ?? null,
165 // import_leads links the campaign; get_job_status only the job list, so the first link stays.
166 appUrl: previous !== undefined ? previous.appUrl : urlOf(p.appUrl),
167 server: previous?.server ?? '',
168 }
169}
170
171/** What an import came to, in a few words: imported and duplicates, or why it failed. */
172export function importOutcome(job: ImportView): string {
173 if (job.status === 'completed') {
174 const dupes = job.duplicates ? ` · ${job.duplicates} Duplikate` : ''
175 return `${job.imported ?? job.received} importiert${dupes}`
176 }
177 const label = IMPORT_STATUS_LABEL[job.status] ?? job.status
178 if (job.isTerminal) return job.message ? `${label} · ${job.message}` : label
179 // The server reports a share only where the job measures one; a bulk import does not.
180 if (job.percent > 0) return `${label} · ${job.percent} %${job.message ? ` · ${job.message}` : ''}`
181 return job.message ? `${label} · ${job.message}` : label
182}
183
184export type LeadRow = {
185 id: number
186 /** The lead's page in the app as the server names it; null when it names none. */
187 appUrl: string | null
188 company: string
189 city: string
190 email: string
191 score: number | null
192 fit: string
193 contactStatus: string
194}
195
196export type LeadList = {
197 campaign: { id: number; name: string; appUrl: string | null } | null
198 leads: LeadRow[]
199 total: number
200}
201
202/** list_leads, search_leads and get_lead_data all answer with leads; this reads the three alike. */
203export function leadsFrom(p: Obj): LeadList | null {
204 const campaign = isObj(p.campaign)
205 ? { id: num(p.campaign.id), name: str(p.campaign.name), appUrl: urlOf(p.campaign.appUrl) }
206 : null
207 const raw = Array.isArray(p.leads) ? p.leads : isObj(p.lead) ? [p.lead] : null
208 if (raw === null) return null
209 const leads = raw.filter(isObj).map(l => {
210 const q = isObj(l.qualification) ? l.qualification : null
211 return {
212 id: num(l.id),
213 appUrl: urlOf(l.appUrl),
214 company: str(l.company) || str(l.website) || str(l.email) || `Lead ${num(l.id)}`,
215 city: str(l.city),
216 email: str(l.sendingEmail) || str(l.email),
217 score: typeof l.score === 'number' ? l.score : null,
218 fit: str(q?.fitLevel),
219 contactStatus: str(l.contactStatus),
220 }
221 })
222
223 return { campaign, leads, total: num(p.total, leads.length) }
224}
225
226/**
227 * Leads whose chain has ended: done with every stage, judged not qualified,
228 * failed, or with nothing left to do. The server chains the stages per lead
229 * and runs the leads side by side, so this is the total less every lead that
230 * still has a job waiting or running. In the moment between one stage ending
231 * and the next being queued a lead counts as through; the next poll corrects it.
232 */
233export function leadsThrough(run: RunView): number {
234 if (run.isTerminal) return run.leadTotal
235 // While the run prepares, or before its first status, no lead has a job yet: none is through.
236 const hasWork = run.stages.some(s => (s.pending || 0) + (s.running || 0) + s.completed + s.failed > 0)
237 if (run.status === 'preparing' || !hasWork) return 0
238 // `|| 0`: a run kept from before `pending` was tracked reads it as absent until the next poll.
239 const open = run.stages.reduce((sum, s) => sum + (s.pending || 0) + (s.running || 0), 0)
240 return Math.max(0, Math.min(run.leadTotal, run.leadTotal - open))
241}
242
243/** A link the server sends, if it is one a Link may draw (https, or http://localhost). */
244export function urlOf(v: unknown): string | null {
245 if (typeof v !== 'string') return null
246 return /^https:\/\/[^\s@]+$/.test(v) || /^http:\/\/localhost(?:[:/]|$)/.test(v) ? v : null
247}
248
249export function bar(done: number, total: number, width = 20): string {
250 const ratio = total > 0 ? Math.min(1, Math.max(0, done / total)) : 0
251 const full = Math.round(ratio * width)
252 return '█'.repeat(full) + '░'.repeat(width - full)
253}
254
255export function appLink(base: string, path: string): string {
256 return new URL(path, base.endsWith('/') ? base : `${base}/`).href
257}
258types/index.d.ts 92 lines1export type StageView = {
2 stage: string
3 /** Leads that reached this stage: jobs created for it so far. */
4 total: number
5 pending: number
6 running: number
7 completed: number
8 /** Ended without success: failed, no result or cancelled. Each ends that lead's chain. */
9 failed: number
10}
11
12export type RunView = {
13 id: string
14 campaignId: number
15 status: string
16 stages: StageView[]
17 leadTotal: number
18 spentUsd: number
19 budgetUsd: number | null
20 isTerminal: boolean
21 isDemo: boolean
22 finishedAt: number | null
23 /** The MCP server the run came from, as its tool names spell it; the band asks that one. */
24 server: string
25 /** When the run was created, epoch ms: the band orders runs by it. */
26 createdAt: number
27 /** The run's page in the app as the server names it, tenant-correct; null when it names none. */
28 appUrl: string | null
29 /** Why a run stopped short, as the server explains it; null while running or when completed. */
30 reason: string | null
31}
32
33export type ImportView = {
34 id: string
35 status: string
36 percent: number
37 message: string
38 received: number
39 campaignId: number | null
40 imported: number | null
41 duplicates: number | null
42 isTerminal: boolean
43 isDemo: boolean
44 finishedAt: number | null
45 appUrl: string | null
46 /** The MCP server the import came from, as its tool names spell it; the band asks that one. */
47 server: string
48}
49
50/** A phase of an outreach workflow that Claude runs itself, with its own agents, instead of on the server. */
51export type LocalRun = {
52 id: string
53 campaignId: number
54 phase: 'qualification' | 'research' | 'email' | 'verify'
55 total: number
56 /** Leads whose result for this phase has been written, by id. */
57 doneLeadIds: number[]
58 /** Leads that left the chain before this phase (judged not qualified): they count as handled. */
59 skippedLeadIds: number[]
60 isTerminal: boolean
61 finishedAt: number | null
62 /** The MCP server the phase writes to, as its tool names spell it; '' until the first write. */
63 server: string
64 /** Share of the weekly subscription window used when the phase began; null off a subscription. */
65 weekStartPercent: number | null
66 /** Share of the five-hour subscription window used when the phase began; null off a subscription. */
67 sessionStartPercent: number | null
68 /** Verify phase only: each lead's verdict by id (freigeben, ablehnen, hinweis). */
69 verdicts?: Record<string, Verdict>
70}
71
72export type Verdict = 'freigeben' | 'ablehnen' | 'hinweis'
73
74declare module 'claude-code' {
75 interface PluginState {
76 'outreach': {
77 /** Runs started or checked in this chat, by id: what the band above the prompt shows. */
78 runs: Record<string, RunView>
79 /** Lead imports started or checked in this chat, by job id: they share the band with the runs. */
80 imports: Record<string, ImportView>
81 /** Workflow phases Claude runs locally in this chat, by campaign and phase. */
82 locals: Record<string, LocalRun>
83 /** App origin per outreach MCP server, learned from the links its answers carry. */
84 origins: Record<string, string>
85 /** Spinner frame of the band, advanced only while a run is going. */
86 frame: number
87 /** The band shrunk to one summary line (`/outreach-status zu`); `/outreach-status auf` opens it again. */
88 folded: boolean
89 }
90 }
91}
92