| name | coldmail |
| description | Send compliant B2B outreach campaigns through a running Coldmail server via JSON API. Drives the full procedure end-to-end: sender profile setup, lead discovery, draft generation, user approval, Stripe payment, admin review, and reply inbox handling. Default server is https://coldmail-agent.aifabrico.com/. Use when the user wants to send cold mail, check campaign status, read replies, or pay for sending. Do not use to scrape restricted sources, bypass admin review, or send unapproved mail. |
Coldmail Skill (client operator runbook)
You operate a running Coldmail server through its JSON API (/api/v1). You drive the outreach procedure; the server enforces every compliance gate. Your job is to parse intent, call endpoints in the right order, show the user the right facts at each gate, and never imply a gate can be skipped.
This skill does not build the Coldmail server. For building/maintaining the app, see references/development.md.
Default server base URL: https://coldmail-agent.aifabrico.com/api/v1 (Herd dev). Override with COLDMAIL_API_BASE_URL. COLDMAIL_API_BASE_URL already includes the /api/v1 suffix — that is exactly what install.sh and smoke-test.sh write — so the curl examples below append the path directly: "$COLDMAIL_API_BASE_URL/me" resolves to https://coldmail-agent.aifabrico.com/api/v1/me. The absolute paths shown in the tables (e.g. /api/v1/me) are the full server routes.
(a) When to use vs not use
Use this skill when the user wants to:
- Send B2B cold outreach ("send mail to transport companies", "napisz maila do firm transportowych", "Mail an Logistikfirmen in Berlin").
- Check a campaign's status, payment, or delivery counts.
- Read or reply to inbound responses.
- Pay for / approve a campaign, or find out why one is stuck.
Do not use this skill (refuse the unsafe part, offer the compliant path) when the user wants to:
- Scrape LinkedIn, Google results, or any restricted source — only backend discovery providers are allowed.
- Send without a complete sender profile, without an approved draft, without explicit approval, or before payment + admin review.
- Hide sender identity, fabricate a relationship, or strip the unsubscribe line.
- Bypass admin review, or burst-send from a web request.
(b) Credentials resolution order
Resolve credentials in this order (first match wins):
- Environment variables
COLDMAIL_API_BASE_URL and COLDMAIL_API_TOKEN.
${XDG_CONFIG_HOME:-$HOME/.config}/coldmail/credentials — INI-style with keys api_base_url and api_token.
$HOME/.coldmail/credentials — fallback.
Send the token on every request: Authorization: Bearer <token>. Tokens are user-owned (Sanctum); every call operates inside the token owner's data. Never log in with email + password from the agent — only use the bearer token. The token never gives you the user's password.
If credentials are missing, auto-provision a device token — no signup, no browser. This is the default
path: run the bundled script, which generates a stable device id (stored locally), exchanges it for a token,
and writes the credentials file. The user registers nothing; they only pay later, per campaign.
bash "$(dirname "$0")/scripts/provision-device.sh"
Under the hood: POST /api/v1/auth/device-register {device_id, device_name} (throttled, no auth) returns a
bearer token bound to an anonymous, device-scoped workspace. The same device_id always maps to the same
workspace, so re-running is safe. Tell the user: "Setting you up — no account needed; you'll only pay per
campaign." This does not weaken any send gate: the device user still completes a sender profile (real
sender identity), pays, and passes admin review before any mail goes out.
Alternative paths (only if the user asks to tie the workspace to a real account): OAuth device-authorization
(POST /api/v1/auth/device-authorize → approve in browser → poll POST /api/v1/auth/device-token every
interval seconds, 5s; do not poll faster or you get slow_down), or, for headless CI, POST /api/v1/auth/token
with email + password. Never log in with email + password interactively — prefer the device token.
Validate any credentials problem with the installed smoke test (calls GET /api/v1/health then GET /api/v1/me):
bash skills/coldmail/scripts/smoke-test.sh
(c) Intent triage — first action
| User intent (any language) | First action | Then |
|---|
| "Send mail to " / "mail do firm…" / "Mail an…" | GET /api/v1/me (verify token + profile) | Run the 10-step procedure from step 1 |
| "What's the status of campaign X?" | GET /api/v1/campaigns/{id}/status | Report counts; see assets/status-report-template.md |
| "Did anyone reply?" / "Read my replies" | GET /api/v1/inbox | GET /api/v1/inbox/{id} for a thread; offer to draft a reply |
| "Pay for campaign X" / "send me the payment link" | GET /api/v1/campaigns/{id}/status then POST /api/v1/campaigns/{id}/payment/checkout | Show checkout_url; explain webhook + admin review |
| "Is it sending yet?" (paid campaign) | GET /api/v1/campaigns/{id}/status | If pending_admin_review, explain human review; poll on cadence |
| "Set up my sender details" | GET /api/v1/sender-profile | Collect missing_fields, then PUT /api/v1/sender-profile |
(c2) Quick start — the simplest client run
This is the canonical happy path. A freshly-installed client should be able to type one sentence
("wyślij maile do firm logistycznych z Polski" / "send emails to logistics firms in Poland") and be
guided through to payment. Name the step as you go; the detailed contract is in section (d).
- First run only — sender profile.
GET /api/v1/me; if sender_profile is null/incomplete, collect the missing fields once and PUT /api/v1/sender-profile. It is remembered for every future campaign, so returning clients skip this entirely.
- One sentence starts it. Translate the request to English and
POST /api/v1/lead-search/preview with explicit English industry/city/country. Discovery searches the client's own base (available_emails, seeded manually or via CSV — it never scrapes). If the counts are 0, the base is empty: point them to the Available Emails page (manual add or CSV import; sample at assets/sample-available-emails.csv).
- Show summary + draft + price, THEN pay. Report the counts +
estimated_price_before_sending, import (confirmed:true), create the campaign, generate and show the email draft, and show the binding quote. The client approves the draft and the price before paying — never send a payment link before the draft is shown.
- Pay. On approval,
POST /api/v1/campaigns/{id}/payment/checkout → give the client checkout_url. Payment is confirmed by Stripe webhook only, never the success URL.
- Owner approval (kept). After payment the campaign is
pending_admin_review. The Coldmail owner approves it in the admin panel (web-only — the skill cannot approve). Poll GET /api/v1/campaigns/{id}/status until admin_review_status=approved (campaign → queued/sending) or admin_rejected.
- Replies via
read. Default routing is app: the client reads replies with "read my replies" → GET /api/v1/inbox, and a thread via GET /api/v1/inbox/{id}. (A campaign may opt into forward-to-email instead, but app/read is the default and also catches unsubscribe intent.)
(d) The 10-step sending procedure
This is the contract. Do not skip steps. Do not invent new ones. Name the step you are on to the user ("Step 6 of 10: campaign approval").
Step 1 — Verify connectivity and identity
GET /api/v1/me
curl -s "$COLDMAIL_API_BASE_URL/me" -H "Authorization: Bearer $COLDMAIL_API_TOKEN"
Capture user, sender_profile (may be null), sender_profile.completed_at, inbound.domain, inbound.default_routing.
Show the user: who they are signed in as, and whether the sender profile is complete.
Step 2 — Ensure sender profile is complete
GET /api/v1/sender-profile → if complete is false, read missing_fields, collect them, then PUT /api/v1/sender-profile.
Required fields: full_name, job_title, company_name, company_website, sender_email, reply_to_email, sender_domain, physical_address, signature, product_summary, default_language, allowed_regions. (reply_to_email is required; reply_forward_email is only required when reply_routing=forward.)
curl -s -X PUT "$COLDMAIL_API_BASE_URL/sender-profile" \
-H "Authorization: Bearer $COLDMAIL_API_TOKEN" -H "Content-Type: application/json" \
-d '{
"full_name":"Jane Doe","job_title":"Head of Sales","company_name":"Acme",
"company_website":"https://acme.com","sender_email":"jane@acme.com",
"reply_to_email":"replies@acme.com","sender_domain":"acme.com",
"physical_address":"1 Main St, City","signature":"Jane Doe, Acme",
"product_summary":"We do X.","default_language":"en","allowed_regions":["DE","PL"]
}'
Default routing is app (replies land in the in-app inbox). The user may switch to forward and supply reply_forward_email. If unspecified, keep app.
Show the user: the exact missing fields before asking, then confirmation that the profile is complete.
Step 3 — Discover or import recipients
POST /api/v1/lead-search/preview for natural-language requests, then POST /api/v1/lead-search/{id}/import.
Normalize to English before sending. Parse the user's request in any language, but translate the
industry, target role, city, and country to English and pass them as the explicit industry / city /
country / target_role fields (in addition to the raw query). The server matches the user's own email
base on these English values, so the base can be kept English-only — you do not store translations. Keep the
original phrasing in query for the record; the explicit fields override the parser.
Discovery searches a database base (never scrapes). The default provider (database) matches rows in an
available_emails pool. Two server modes — branch on the shared_base field in the preview response:
shared_base: true (shared owner base). Counts come from the OWNER's curated base; the client gets
counts + price only and never the emails. The client does NOT create a campaign or import. Instead they
buy a counted batch: POST /api/v1/orders {lead_search_id, value_proposition, cta, ...} → POST /api/v1/orders/{id}/checkout
for the Stripe link → poll GET /api/v1/orders/{id} until status is paid then completed. The owner
fulfils the send in their workspace. Requires the client's sender profile complete (they are the sender of record).
shared_base: false (per-user base). The client searches their own pool; if eligible_emails_count: 0
the base is empty — tell them to add emails on the Available Emails page or import a CSV (sample:
assets/sample-available-emails.csv). They then follow the normal
campaign path (steps 4–8).
Keep industries/cities English in the base so any-language searches match.
curl -s -X POST "$COLDMAIL_API_BASE_URL/lead-search/preview" \
-H "Authorization: Bearer $COLDMAIL_API_TOKEN" -H "Content-Type: application/json" \
-d '{"query":"wyślij maile do firm księgowych z Warszawy","industry":"accounting","city":"Warsaw","country":"Poland","max_results":100}'
Show the user (use assets/campaign-preview-template.md): companies_found_count, unique_emails_count, eligible_emails_count, excluded_emails_count, exclusion_summary, and estimated_price_before_sending (note: this preview price is an estimate derived from the server pricing config — same currency as the binding quote, e.g. PLN; the binding price still comes from the campaign quote in step 6). If status is not completed, surface missing_required_fields and ask for them (often a missing region).
Ask for explicit confirmation, then import:
curl -s -X POST "$COLDMAIL_API_BASE_URL/lead-search/{id}/import" \
-H "Authorization: Bearer $COLDMAIL_API_TOKEN" -H "Content-Type: application/json" \
-d '{"confirmed": true}'
Nothing sends at this stage. Details: references/backend-lead-discovery.md.
Step 4 — Create campaign
POST /api/v1/campaigns
Required fields include natural_language_request, relevance_reason, prospect_source (discovery|prospect_list|manual_prospects|csv_imported), tone, value_proposition, cta, follow_up_count, daily_limit, timezone, and compliance fields contact_source, legal_basis, unsubscribe_text, sender_identity. Default language: en. For prospect_source=discovery, pass lead_search_id and confirmed_import: true.
curl -s -X POST "$COLDMAIL_API_BASE_URL/campaigns" \
-H "Authorization: Bearer $COLDMAIL_API_TOKEN" -H "Content-Type: application/json" \
-d '{
"natural_language_request":"mail do firm transportowych",
"relevance_reason":"They run regional freight and need faster dispatch.",
"prospect_source":"discovery","lead_search_id":12,"confirmed_import":true,
"language":"en","tone":"professional",
"value_proposition":"Cut dispatch time 20%.","cta":"Open to a 15-min call?",
"follow_up_count":1,"daily_limit":50,"timezone":"Europe/Warsaw",
"contact_source":"Public company website","legal_basis":"Legitimate interest (B2B)",
"unsubscribe_text":"Reply STOP to opt out.","sender_identity":"Jane Doe, Acme"
}'
Campaign is created with status: pending_approval and a draft quote. Show the user: the created campaign id and that a draft quote was generated.
Step 5 — Generate and approve a draft
POST /api/v1/campaigns/{id}/drafts/generate, then POST /api/v1/campaigns/{id}/drafts/{draftId}/approve.
curl -s -X POST "$COLDMAIL_API_BASE_URL/campaigns/{id}/drafts/generate" \
-H "Authorization: Bearer $COLDMAIL_API_TOKEN"
Show the user the draft subject and body_text. They pick one; approve it:
curl -s -X POST "$COLDMAIL_API_BASE_URL/campaigns/{id}/drafts/{draftId}/approve" \
-H "Authorization: Bearer $COLDMAIL_API_TOKEN"
Approval sets approved_at. At least one approved draft is required before campaign approval.
Step 6 — Show price, get explicit campaign approval
GET /api/v1/campaigns/{id} to read the quote, then POST /api/v1/campaigns/{id}/approve.
Show the user quote.eligible_recipient_count, quote.total_amount_minor, and quote.currency before asking for approval. The accepted quote is immutable.
curl -s -X POST "$COLDMAIL_API_BASE_URL/campaigns/{id}/approve" \
-H "Authorization: Bearer $COLDMAIL_API_TOKEN" -H "Content-Type: application/json" \
-d '{"approval_confirmation": true}'
On success the campaign moves to payment_pending and the quote is accepted. A gate failure returns 422 with an exact message — surface it verbatim. See references/compliance-guardrails.md.
Step 7 — Stripe Checkout
POST /api/v1/campaigns/{id}/payment/checkout returns checkout_url.
curl -s -X POST "$COLDMAIL_API_BASE_URL/campaigns/{id}/payment/checkout" \
-H "Authorization: Bearer $COLDMAIL_API_TOKEN"
Show the user the checkout_url and amount_minor/currency. They pay in the browser. Payment is confirmed only by a Stripe webhook on the server — never assume payment is done from the success URL. Details: references/stripe-payment-gate.md.
Step 8 — Wait for admin review (human gate)
After the webhook confirms payment, the campaign becomes pending_admin_review (with admin_review_status: pending) — not queued. Tell the user:
"Payment received. The campaign is now waiting for human admin review on the Coldmail side. Sending starts automatically after the admin approves it. I'll keep checking."
Poll GET /api/v1/campaigns/{id}/status on a sane cadence (see section (g)) until admin_review_status is approved (campaign → queued) or the campaign becomes admin_rejected (read admin_review_notes). The API cannot clear admin review — that is web-only. See references/admin-review.md.
Step 9 — Sending and delivery
Once status is queued or sending, mail is going out (queued + rate-limited; never bulk-sent from a request).
curl -s "$COLDMAIL_API_BASE_URL/campaigns/{id}/status" \
-H "Authorization: Bearer $COLDMAIL_API_TOKEN"
Show the user recipients.sent, .delivered, .bounced, .replied, .unsubscribed, .failed. Final status is completed. Status fields: references/status-and-events.md.
Step 10 — Read and answer replies
GET /api/v1/inbox lists inbound replies; GET /api/v1/inbox/{id} shows the full thread; POST /api/v1/inbox/{id}/reply sends a reply.
curl -s -X POST "$COLDMAIL_API_BASE_URL/inbox/{id}/reply" \
-H "Authorization: Bearer $COLDMAIL_API_TOKEN" -H "Content-Type: application/json" \
-d '{"body_text":"Thanks for the reply — here are the details..."}'
Replies are stored only in app mode (default). In forward mode, replies bypass the app and go to reply_forward_email, so the inbox stays empty and the app cannot detect unsubscribe intent. Show the user a summary of the reply and ask whether to draft a response.
(e) Non-negotiable gates
Coldmail enforces these server-side. Never present any as skippable:
- Sender profile complete (all required fields).
- Campaign has eligible recipients.
- Each recipient has source metadata + legal/compliance basis.
- Suppressed / complained / hard-bounced / manually-blocked / invalid recipients excluded (re-checked at send time).
- At least one approved email draft.
- Explicit campaign approval by the user.
- CampaignQuote exists and was shown to the user (immutable once accepted).
- Stripe payment confirmed by webhook, never by the success URL.
- Unsubscribe token per recipient; every email carries an opt-out line.
- Sending is queued and rate-limited; never bulk-send from an HTTP request.
- Admin review approves the campaign — a human step after payment. If you tell the user a paid campaign is "queued to send" before admin approval, you are wrong.
reply_routing=app requires COLDMAIL_INBOUND_DOMAIN server-side; reply_routing=forward requires a valid reply_forward_email at send time.
(f) HTTP error handling
| Status | Meaning | What you do |
|---|
401 | Unauthenticated — token missing/invalid/revoked | Stop. Re-resolve credentials; if absent, auto-provision with scripts/provision-device.sh (section b). Do not retry with email/password. |
403 | This action is unauthorized. — record owned by another user | Stop. The id belongs to someone else. Confirm the id with the user; never attempt cross-user access. |
404 | Model not found (unknown id, or draft not in campaign) | Re-fetch the list (GET /api/v1/campaigns or /inbox) to get a valid id. Don't fabricate ids. |
422 | Validation or a compliance-gate failure: {"message","errors":{...}} or {"message":"<gate text>"} | Surface the exact message/errors. Fix the named field or gate (e.g. approve a draft, supply legal basis) and retry once. Do not retry blindly. |
400 | Device-flow poll outcome: authorization_pending, slow_down, expired_token, access_denied, invalid_grant | authorization_pending/slow_down: keep polling at ≥5s. expired_token/access_denied: restart device authorize. |
5xx | Server error. Note: an incomplete sender profile during campaign create/approve/checkout surfaces as HTTP 500 (not 422), message starting A complete sender profile is required… | Read the body. If it names missing sender-profile fields, go back to step 2 and complete them. Otherwise report the raw error and stop. |
Full contract and response shapes: references/api-reference.md. Agent triage runbook: references/troubleshooting.md.
(g) Polling / scheduling for admin review
- Poll
GET /api/v1/campaigns/{id}/status no faster than once every 30 seconds.
- For admin review (step 8), a sane cadence is every 1–2 minutes; admin review is a human step and can take hours. Don't spin a tight loop.
- Stop polling when
admin_review_status is approved (campaign → queued/sending) or the campaign is admin_rejected.
- If the user steps away, offer to check again later rather than blocking. Surface
admin_review_notes verbatim on rejection.
(h) Reporting status to the user
- Always name the step you are on ("Step 8 of 10: admin review").
- For status reports, fill in assets/status-report-template.md with the live
status fields: payment status, admin review status, and recipient counts (sent/delivered/bounced/replied/unsubscribed/failed).
- After payment (step 8), proactively explain that admin review is human and may take time; offer to poll on the cadence above.
- After a reply (step 10), summarize it and ask whether to draft a response.
- If an API error is opaque, quote the raw JSON
message/errors to the user; do not paraphrase a gate failure.
(i) Forbidden behavior
- Do not scrape LinkedIn, Google results, or any restricted source. Use only backend discovery providers.
- Do not invent recipients, source metadata, or legal basis. Pass exactly what the user supplied.
- Do not hide sender identity, fabricate a relationship, or remove the unsubscribe line.
- Do not call admin endpoints unless the user explicitly says they are an admin and
me.user.is_admin is true — and note the API has no admin-review approve/reject endpoint regardless.
- Do not poll status faster than every 30 seconds.
- Do not retry token issuance with email/password — only use the bearer token from credentials.
(j) Quick endpoint reference
Full catalog with request/response shapes: references/api-reference.md.
| Step | Method | Path |
|---|
| Health | GET | /api/v1/health |
| Identity | GET | /api/v1/me |
| Orders (shared base: buy a counted batch) | POST/GET | /api/v1/orders, /api/v1/orders/{id}, /api/v1/orders/{id}/checkout |
| Device register (zero-signup, default) | POST | /api/v1/auth/device-register |
| Device authorize / token (tie to an account) | POST | /api/v1/auth/device-authorize, /api/v1/auth/device-token |
| Sender profile | GET / PUT | /api/v1/sender-profile |
| Discover leads | POST | /api/v1/lead-search/preview |
| Import leads | POST | /api/v1/lead-search/{id}/import |
| Create campaign | POST | /api/v1/campaigns |
| Campaign detail | GET | /api/v1/campaigns/{id} |
| Generate drafts | POST | /api/v1/campaigns/{id}/drafts/generate |
| Approve draft | POST | /api/v1/campaigns/{id}/drafts/{draftId}/approve |
| Approve campaign | POST | /api/v1/campaigns/{id}/approve |
| Stripe checkout | POST | /api/v1/campaigns/{id}/payment/checkout |
| Campaign status | GET | /api/v1/campaigns/{id}/status |
(k) References and worked example