createDrafter
Generate new text in a specific persona/voice. Returns plain text. Default temperature 0.4 (creative but controlled).
The persona is the most important configuration: it tells the model who is "writing." Channel constraints (SMS = 160 chars, email = 150-250 words) help size the output. Anti-pattern blacklists eliminate AI-isms ("I wanted to reach out", "I hope this finds you well", etc.).
Signature
function createDrafter(config: {
port: LLMPort;
schemaName?: string; // default "draft"
persona: Resolvable<DraftInput, string>; // REQUIRED
channelConstraint?: Resolvable<DraftInput, string>;
antiPatterns?: Resolvable<DraftInput, string>;
writingSamples?: Resolvable<DraftInput, string>;
systemContext?: Resolvable<DraftInput, string>;
taskType?: string; // default "draft"
priority?: LLMPriority;
temperature?: number; // default 0.4
maxLength?: number; // hard character cap; truncates output
maxOutputTokens?: number;
onBeforeCall?: (input: DraftInput) => void | Promise<void>;
onResult?: (event: CapabilityEvent<string>) => void | Promise<void>;
onError?: (error: Error, input: DraftInput) => void | Promise<void>;
}): (input: DraftInput) => Promise<string>;
interface DraftInput {
instructions: string;
threadHistory?: MessageContent;
recipientContext?: string;
contextOverride?: string;
}Email drafting
import { createDrafter } from "@llm-ports/capabilities";
export const draftEmail = createDrafter({
port: llm,
persona: `
Babak Abbaschian. Direct, warm, no filler. Short paragraphs (1-3 sentences).
Lead with the answer; explain only when needed.
`,
channelConstraint: "Email. Target 150-250 words. Sign off: 'Babak'.",
antiPatterns: `
Never say:
- "I wanted to reach out"
- "I hope this finds you well"
- "Looking forward to hearing from you"
- "Just wanted to circle back"
- Three consecutive sentences starting with "I"
`,
maxLength: 1500,
});
const draft = await draftEmail({
instructions: "Reply to Alice's intro request. Suggest a 30-min call next week.",
recipientContext: "Alice from Sequoia. Met at All-In summit. Warm.",
});Channel-specific drafters
Production codebases typically have one drafter per channel (SMS, email, LinkedIn DM, Twitter):
export const draftSMS = createDrafter({
port: llm,
persona: BABAK_PERSONA,
channelConstraint: "SMS. HARD LIMIT: 160 characters. No greeting. No sign-off. Match the sender's informality.",
maxLength: 160,
temperature: 0.3, // shorter output, less creative
});
export const draftLinkedInPost = createDrafter({
port: llm,
persona: BABAK_PERSONA,
channelConstraint: "LinkedIn post. Under 1300 chars (before the 'see more' fold). Hook in the first line. No hashtag spam.",
maxLength: 1300,
});Thread history (reply context)
Pass threadHistory so the model sees the conversation context:
const reply = await draftEmail({
instructions: "Reply that we'd like to schedule a follow-up next week.",
threadHistory: previousEmails, // string or ContentBlock[]
recipientContext: "Alice from Sequoia",
});The drafter wraps thread history in <thread>...</thread> tags so the model can distinguish historical context from the current instruction.
Writing samples (style transfer)
Show the model 1-3 examples of correctly-styled output:
export const draftTechnicalEmail = createDrafter({
port: llm,
persona: "engineering lead — concise, specific, no marketing-speak",
writingSamples: `
Sample 1:
"I'm landing a fix for the latency regression today. Three things changed:
- Removed N+1 in /api/users (root cause)
- Cached the org membership lookup (60% of remaining time)
- Bumped Redis pool size from 10 to 30 (avoids saturation under burst)
p99 should drop from 1200ms → ~200ms. I'll watch it for 24h."
`,
});In production, load samples from a database keyed by (channel, register, recipient_type) and pass them through an async writingSamples resolver on the factory — that way new tone exemplars are picked up without a redeploy. A minimal schema:
CREATE TABLE writing_samples (
channel text NOT NULL, -- "email" | "sms" | "linkedin" | ...
register text NOT NULL, -- "warm" | "firm" | "strategic" | ...
recipient text NOT NULL, -- "internal" | "customer" | "investor"
sample_text text NOT NULL,
is_active boolean DEFAULT true,
updated_at timestamptz DEFAULT now()
);Then resolve from the row matching the call site's (channel, register, recipient) triple.
Output truncation
maxLength enforces a hard character cap. If the model overshoots, the result is truncated to maxLength and trimmed. Useful for SMS (Twilio rejects >160 chars), tweet caps (280), LinkedIn fold (1300):
export const draftTweet = createDrafter({
port: llm,
persona: BABAK_PERSONA,
channelConstraint: "Twitter. Max 280 chars. Punchy, no corporate jargon.",
maxLength: 280,
});Reading next
- Tool-use security — drafts going through approval flows
createSummarizer— when you're compressing, not generating new text