SLOPSHOPPER

outreach

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

newbandrowsguardcommandtoast
v0.3.21UNLICENSEDupdated 2026-10-08RankM8/outreach-plugins/plugins/outreach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · outreach
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /outreach-status ⎿ outreach: Gerade läuft kein Lead-Run. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Outreach Plugins

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.

PluginInhaltFür wen
outreachKampagnen 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
datenbeschaffungLeads 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.

Installation

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).

Aufbau

.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/)

Pflege

  • Jede Änderung, die Kunden erreichen soll: 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.
  • MCP-Tool-Änderungen im Produkt → betroffene Skills im selben Zug nachziehen.
  • Live-Ansicht: claude plugin test plugins/outreach; Manifeste: claude plugin validate .
  • Kein bin/-Ordner in einem Plugin (claude.ai lehnt das ganze Plugin sonst ab), keine Symlinks.
  • Actor-Empfehlungen/Preise (datenbeschaffung-referenzen/references/apify-actors.md, kosten.md) tragen ein „zuletzt geprüft“-Datum.
Source 3 files
hooks/register.tsx 1075 lines
1import { 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}
1075
hooks/model.ts 258 lines
1import 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}
258
types/index.d.ts 92 lines
1export 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