@agentled/cli
Version:
CLI for Agentled — manage workflows, apps, and knowledge from the command line. Zero context-window cost for AI agents.
320 lines (245 loc) • 14.7 kB
Markdown
# Pattern 13 — Entity Pipeline Lifecycle
Build lead, account, candidate, deal, or operator-target systems as a small group of workflows connected by one KG list and explicit status transitions.
## When to use
Use this pattern when the user describes a recurring business funnel:
- source targets from one or more channels
- save them to a list
- qualify or score them
- find the right contact
- draft or send outreach with approval
- run the whole process daily or weekly
Do not build this as one large workflow unless the user needs a one-off run. The reusable shape is a workflow group.
## Canonical shape
```
Source Channel A -> kg.upsert-rows(status: "new")
Source Channel B -> kg.upsert-rows(status: "new")
Score / Qualify -> read status "new" -> status "qualified" | "needs_review" | "disqualified"
Find Contact -> read status "qualified" -> status "contact_found" | "contact_missing"
Draft Outreach -> read status "contact_found" -> status "ready_to_contact"
Send / Queue Outreach -> read status "ready_to_contact" -> status "contacted"
Daily / Weekly Orchestrator -> runs the stages in order, computes KPIs, writes a digest
```
The status names are examples. Use domain language that matches the list, then document it.
## Build order
1. Create the KG list first.
2. Build one sourcing workflow that writes rows with `status: "new"` and stable `userKey`.
3. Build one scoring workflow that reads `status: "new"` and writes a decisive next status.
4. Build contact discovery only after scoring produces enough qualified rows.
5. Build outreach as a separate workflow with approval. Never send directly from sourcing or scoring.
6. Build the scheduled orchestrator last. It should coordinate already-working workflows, not hide broken stages.
## Recommended CLI scaffolds
```bash
# Source rows from a native app into a KG list.
agentled workflows scaffold source-from-platform --out 01-source.json
# Score / qualify rows against an ICP and store the result.
agentled workflows scaffold lead-scoring-kg --out 02-score-qualify.json
# Draft/send approval-gated outreach from matched list rows.
agentled workflows scaffold list-match-email --out 04-outreach.json
# Coordinate the recurring funnel once child workflows are working.
agentled workflows scaffold funnel-orchestrator --out 99-orchestrator.json
```
## KG list contract
Every entity list used by this pattern needs:
| Field | Purpose |
|---|---|
| `userKey` | Stable dedupe key. Prefer domain, email, LinkedIn URL, or canonical source URL. |
| `status` | Indexed workflow phase. Every stage reads one status and writes the next. |
| `source` / `source_channel` | Where the row came from. Useful for quality and cost analysis. |
| `sourced_at` | When the row first entered the funnel. |
| score fields | `fitScore`, `fitReasoning`, `qualificationReason`, or domain-specific equivalents. |
| contact fields | `buyerRole`, `contactName`, `contactEmail`, `contactLinkedin`, `contactConfidence`. |
| outreach fields | `outreachAngle`, `firstCallHook`, `notes`, `lastContactedAt`, `nextActionAt`. |
| delivery fields | `messageId`, `threadId`, `channelUsed`, `sentAt`, `deliveryStatus`, `bouncedAt`, `bounceReason`. |
| engagement fields | `openedAt`, `openCount`, `clickedAt`, `clickCount`, `lastClickedUrl`, `repliedAt`, `replySentiment`. |
Use `mergeStrategy: "merge"` whenever a source workflow upserts rows. Re-sourcing the same entity must not wipe scoring or outreach fields.
## Status rules
1. Each workflow reads exactly one input status.
2. Each workflow writes a forward status, never back to an earlier phase.
3. Write status before customer-visible side effects when possible. This prevents duplicate sends on retries.
4. Use a review status for uncertain rows instead of forcing them into qualified or disqualified.
5. Keep disqualified rows. They are calibration data for future sourcing and scoring.
## Sourcing workflow
Purpose: create raw rows only.
```
trigger -> source/search app -> normalize -> kg.upsert-rows(status: "new") -> done
```
Rules:
- Do not score in the source workflow.
- Do not find contacts in the source workflow unless the source returns them cheaply.
- Do not draft outreach in the source workflow.
- Do write `source_channel`, `source_url`, and enough evidence for the scorer to judge quality.
## Score / qualify workflow
Purpose: be strict and reduce noise.
```
trigger -> kg.read-list(status: "new") -> read ICP/rubric -> score rows -> kg.upsert-rows(status buckets) -> done
```
Use three buckets:
- `qualified`: ready for contact discovery or outreach drafting
- `needs_review`: plausible, but a human should inspect before outreach
- `disqualified`: do not contact
If the list schema does not allow `needs_review`, use the workspace's closest status such as `enriched` or `review`.
## Contact discovery workflow
Purpose: find one realistic buyer path for qualified rows.
```
trigger -> kg.read-list(status: "qualified") -> find person/email/LinkedIn -> verify -> kg.upsert-rows(status: "contact_found" or "contact_missing")
```
Rules:
- Use the best available source for the channel: LinkedIn/profile enrichment for the person path, email-finder/verification actions for email, and company-domain fallback only when no person-level contact is available.
- Store both person and channel fields when found: `contactName`, `buyerRole`, `contactLinkedin`, `contactEmail`, `contactSource`, `contactConfidence`.
- Prefer a likely role over invented person details.
- Keep confidence fields.
- Put missing-contact rows in their own status so they can be retried later with a different provider.
## Outreach workflow
Purpose: prepare or send customer-visible messages with approval.
```
trigger -> kg.read-list(status: "contact_found") -> draft message -> approval -> schedule-email -> kg.upsert-rows(status: "contacted")
```
For email, use the composed email pattern:
- `pipelineStepPrompt.type: "email"`
- `renderer: { type: "Email", config: { fromContextKey: "outreachProfile" } }`
- `onApproval.action: "schedule-email"`
- `next.conditions.approvalRequired: true`
If the user only wants drafts, write the draft into `notes` and move rows to `ready_to_contact`. Do not send.
Mark a row `contacted` only after the send/queue action succeeds, not when the draft is generated. Store the send result fields (`messageId`, `threadId`, `channelUsed`, `sentAt`, `lastContactedAt`) at the same time so future follow-up workflows can dedupe and thread replies.
### Approval step examples
**Email send with approval**
Use the first-class composed email shape. This is the only supported pattern for approval-gated email sending; do not model this as `draft-email -> gmail.send-email`.
```json
{
"id": "send-outreach-email",
"type": "aiAction",
"name": "Send Outreach Email",
"pipelineStepPrompt": {
"type": "email",
"template": "Draft a concise outreach email for this target.\\n\\nTarget row:\\n{{steps.read-target.rows.0}}\\n\\nUse the outreach angle and first-call hook already stored on the row. Return email-safe HTML only.",
"responseStructure": {
"email": {
"from": "{{context.outreachProfile.fromEmail}}",
"to": "{{steps.read-target.rows.0.contactEmail}}",
"subject": "",
"body": "",
"bodyType": "html"
}
},
"responseType": "json"
},
"renderer": {
"type": "Email",
"config": { "fromContextKey": "outreachProfile" }
},
"integrations": [
{
"type": "oneOf",
"label": "Email",
"connectorType": "email",
"options": [
{ "name": "Gmail", "url": "https://gmail.com", "isUserAccountConnectionRequired": true },
{ "name": "Outlook", "url": "https://outlook.com", "isUserAccountConnectionRequired": true }
],
"selectionHint": "preferConnected"
}
],
"onApproval": {
"executedText": "Email sent by {{name}} at {{date}}",
"scheduledText": "Email scheduled for {{date}} by {{name}}",
"failedText": "Email failed to send.",
"action": "schedule-email"
},
"creditCost": 5,
"next": {
"stepId": "mark-contacted",
"conditions": { "approvalRequired": true }
}
}
```
`mark-contacted` should run after the email step completes and upsert the row with `status: "contacted"`, `channelUsed: "email"`, `sentAt`, `lastContactedAt`, and any returned `messageId` / `threadId`.
**LinkedIn connection note with approval**
Use the `LinkedInMessage` renderer for the approval UI. A LinkedIn connection note is copy-only unless the workspace has a real LinkedIn outreach integration connected and verified. Do not use `schedule-linkedin-content`; that action is for publishing posts, not sending connection notes.
```json
{
"id": "draft-linkedin-connection-note",
"type": "aiAction",
"name": "Draft LinkedIn Connection Note",
"description": "Draft a LinkedIn connection note for review.",
"pipelineStepPrompt": {
"type": "analysis",
"template": "Draft a LinkedIn connection note for this target.\\n\\nTarget row:\\n{{steps.read-target.rows.0}}\\n\\nRules:\\n- connectionNote.body must be 300 characters or less\\n- no links\\n- plain text only\\n- mention the most concrete manual operation angle from outreachAngle or firstCallHook\\n\\nReturn JSON only.",
"responseStructure": {
"outreachMessage": {
"connectionNote": {
"mode": "connection_note",
"body": "≤300 chars, no links"
},
"linkedinUrl": "{{steps.read-target.rows.0.contactLinkedin}}",
"recipientName": "{{steps.read-target.rows.0.contactName}}"
},
"reasoning": "Why this note is relevant"
},
"responseType": "json"
},
"renderer": {
"type": "LinkedInMessage",
"config": { "responseField": "outreachMessage" }
},
"onApproval": {
"executedText": "LinkedIn connection note approved by {{name}} at {{date}}",
"scheduledText": "Waiting for approval to proceed",
"failedText": "Failed to proceed to next step",
"action": "PROCEED"
},
"creditCost": 3,
"next": {
"stepId": "queue-linkedin-note",
"conditions": { "approvalRequired": true }
}
}
```
Then add a downstream step that either calls the verified LinkedIn/PhantomBuster connection-note action or queues the payload for manual send:
```json
{
"id": "queue-linkedin-note",
"type": "code",
"name": "Queue LinkedIn Connection Note",
"description": "Return the approved LinkedIn note payload for manual send or for a connected LinkedIn outreach action.",
"codeConfig": {
"language": "javascript",
"code": "const message = {{steps.draft-linkedin-connection-note.outreachMessage}};\\nif (!message?.linkedinUrl) return { queued: false, reason: 'missing_linkedin_url' };\\nif (!message?.connectionNote?.body || message.connectionNote.body.length > 300) return { queued: false, reason: 'invalid_connection_note' };\\nreturn { queued: true, channelUsed: 'linkedin_connection_note', linkedinUrl: message.linkedinUrl, note: message.connectionNote.body };"
},
"next": { "stepId": "mark-linkedin-contacted" }
}
```
Only mark the row `contacted` after the real send/queue step succeeds. For manual LinkedIn sends, use a status such as `linkedin_note_queued` or `ready_for_manual_linkedin` until a user confirms the note was actually sent.
## Engagement tracking workflow
Purpose: update rows after outreach based on provider events or inbox polling.
```
email/linkedin event or polling -> match message/thread/contact -> kg.upsert-rows(status or tracking fields) -> optional follow-up workflow
```
Track these events when the channel supports them:
- `delivered`: set `deliveryStatus: "delivered"` and `deliveredAt`.
- `bounced`: set `deliveryStatus: "bounced"`, `bouncedAt`, `bounceReason`, and usually stop follow-ups.
- `opened`: set `openedAt` on first open and increment `openCount`.
- `clicked`: set `clickedAt`, increment `clickCount`, and store `lastClickedUrl`.
- `replied`: set `repliedAt`, classify `replySentiment`, and move to a terminal or review status such as `replied_interested`, `replied_declined`, or `reply_needs_review`.
Agentled workflow email supports open/click tracking for HTML emails when tracking is enabled: the email service injects an open pixel and rewrites links through tracking redirects, then writes `TrackingEvent` rows with `eventType: "opened"` or `eventType: "clicked"`.
Important: opens and clicks are still not guaranteed signals. Some recipients block images, prefetch links, or use privacy proxies. Treat them as engagement hints, not proof that a human read the message. Delivery and bounce tracking are provider-event concerns and should be handled separately from open/click tracking.
## Orchestrator workflow
Purpose: run the machine on a cadence and report health.
```
schedule -> read new count -> call scoring workflow -> wait for loop completion
-> read qualified count -> call contact/outreach workflow
-> compute KPIs -> share/send digest -> done
```
Build the orchestrator after the child workflows validate and have passed at least one manual run. It should use `agentled.call-workflow` or `agentled.start-workflow` and loop over rows or stages with `loop_completion` gates.
## Outreach metrics
For outbound or email-heavy entity pipelines, configure business metrics in `analyticsConfig`:
- `prospects_contacted`: count or extract rows moved to a contacted/sent status.
- `positive_replies`: count or extract rows moved to `replied_positive`, `replied_interested`, `meeting_scheduled`, `paid`, or the workspace's equivalent positive outcome.
- `pcpl`: prospects contacted per positive lead, computed as `prospects_contacted / positive_replies` with `ratioMode: "raw"`.
- `positive_reply_rate`: optional percentage-style ratio, `positive_replies / prospects_contacted`.
- `meetings_booked` / `paid_confirmations`: downstream conversion, if the workspace tracks it.
- `bounce_count` / `failed_send_count`: deliverability guardrail, if available.
Literal PCPL should use `type: "ratio"` with `ratioMode: "raw"`. A plain ratio metric without `ratioMode` keeps the legacy percentage behavior for rates.
## What to tell the user
Explain this pattern in business terms:
> "We will create a simple operating funnel: one workflow finds candidates, one decides who is worth contacting, one finds the right contact, one prepares outreach for approval, and one scheduled workflow keeps the whole process moving."
Avoid describing this as an AI stack, orchestration graph, or automation framework unless the user asks for implementation details.