DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our drafted reply arrives with gaps in square brackets, and the send action refuses to send one

Nakodo runs a brand's creator outreach: it writes the first email, follows up, and answers what it can from the brand's own notes. Some questions it must not answer. A creator asking "do you want usage rights, and for how long?" is asking about something the brand has either decided or has not, and no amount of fluent writing turns a guess into a decision. Our glossary entry on usage rights exists because that question comes up constantly, and the how it works page promises the behaviour in those words: anything else, such as contracts, more commission or anything unclear, comes to the brand, and their answer goes out in the same conversation.

Asking the brand used to mean handing them an empty textarea, which is the one place in the whole product where a campaign still said "you write it". So now there is a drafted reply. The model is given the same facts the incoming reply was read with, plus the question the brand has been left with, and it writes as much of the answer as those facts allow. Everything the facts do not settle is left as a gap in square brackets:

The box would arrive [how long after it's sent]

The instruction text stays in the repo, as with our other outreach prompts. What is worth writing down is the machinery either side of it, because a draft that helps and a draft that leaks a note to the brand into a creator's inbox are the same string until something checks.

Six lines that decide whether Send works

// The gaps of a suggested reply still in what the brand is about to send,
// exactly as they were left: "[the most you would pay]". Sending one would
// put Nakodo's note to the brand in front of the creator.
export function unfilledGaps(suggested: string | null | undefined, body: string): string[] {
  if (!suggested) return [];
  const gaps = [...suggested.matchAll(/\[[^[\]\n]{1,160}\]/g)].map((m) => m[0]);
  return [...new Set(gaps)].filter((g) => body.includes(g));
}
Enter fullscreen mode Exit fullscreen mode

Four decisions in there:

It compares against the draft, not against brackets in general. A brand writing "we ship to the EU [and the UK from November]" is writing their own prose. Only the exact substrings the draft left behind count, which is why the draft is stored on the thread and passed back in.

The character class excludes [, ] and newlines. A greedy match across two gaps would produce one enormous phantom gap and an error message quoting half the email. Bounded at 160 characters for the same reason.

The Set is there because a gap can repeat. Two mentions of [your shipping country] are one thing to fill in, and the error message should not say so twice.

Order is preserved. Set keeps insertion order, so the first gap named in the error is the first one in the email, which is where the brand's cursor should go.

The refusal is a sentence, not a code

The check lives in the server action that sends, between the length validation and the send itself:

const gaps = unfilledGaps(thread.suggestedReply, text.data);
if (gaps.length > 0) {
  const more = gaps.length - 1;
  return fail(`Fill in ${gaps[0]} before sending${more ? `, and the ${more === 1 ? "other gap" : `other ${more} gaps`} in square brackets` : ""}.`);
}
Enter fullscreen mode Exit fullscreen mode

So the brand gets "Fill in [how long after it's sent] before sending." or "... before sending, and the other 2 gaps in square brackets." The gap is quoted with its brackets because that is the string they are hunting for in the box.

It matters that this is in the action rather than in the component. The textarea is the brand's to do anything with, there is a "start from blank" link, and the draft is suggestion-shaped rather than form-shaped. The only invariant worth enforcing is the one at the boundary: a bracketed gap from our own draft must not be the thing that goes out over the brand's name.

The client half is three small React decisions

// `suggestion` is a reply Nakodo drafted for a question only the brand can
// answer. It starts in the box with its gaps in square brackets, to change,
// replace or clear: nothing goes until Send.
const [text, setText] = useState(suggestion ?? "");
const drafted = !!suggestion && text === suggestion;
Enter fullscreen mode Exit fullscreen mode

useState(suggestion ?? "") only reads the prop on mount, which is exactly right for a draft and exactly wrong for a panel that swaps conversations under the same component. The call site handles that with key={thread.id}, so moving to another conversation remounts the box rather than carrying the previous draft into it.

drafted is an equality check against the untouched draft, and it drives the hint that explains the brackets. Type one character and the hint goes, because at that point the brand knows what they are doing and does not need telling twice.

The third decision is in the comment above the component:

// Writing to them yourself, with the conversation's other actions beside Send.
// Those sit outside the form, so pressing Send is the only way what's in the
// box goes out: no other button and no shortcut.
Enter fullscreen mode Exit fullscreen mode

Stop, Say no, Agree and Reopen all live next to Send and all submit nothing. A stray Enter in a form containing five buttons is how a half-filled draft gets mailed to a stranger.

The draft is cleared when the question stops being the question

Reopening a stopped conversation resets the thread, and the drafted reply goes with it:

.set({ status: "needs_you", closedReason: null, closedAt: null, nextActionAt: null, note: "Reopened. Write to them below.", suggestedReply: null })
Enter fullscreen mode Exit fullscreen mode

A draft is an answer to one specific question. Keeping it across a reopen would mean the box helpfully pre-fills an answer to something nobody asked, with gaps whose meaning has quietly changed. null is cheaper and more honest.

The one that pays for itself twice

If the question turns out to be general rather than personal, sending the reply can also save it. The reply box offers a "keep this for other creators" tick, and the action merges the question and answer into the campaign's details with a JSONB concat rather than a read-modify-write:

.set({ outreach: sql`${campaigns.outreach} || jsonb_build_object('details', ${JSON.stringify(kept)}::jsonb)` })
Enter fullscreen mode Exit fullscreen mode

So the brand answers "do you want usage rights" once, and the next creator who asks gets an answer without the brand being interrupted at all. That is the real goal of the whole feature, and the bracketed gap is just the shape of an honest "I do not know this".

Two tests cover the input building rather than the model: that the draft is asked for with the same facts the incoming reply was read with, and that an empty question falls back to "see their reply above". The drafting itself is a model call and gets exercised by our reply simulator instead, which runs whole conversations with no sends at all. If you want to see what the brand sees at this point in a campaign, the how it works page walks through replies and introductions, and the pricing page spells out that answers and introductions are included rather than counted again.

Top comments (0)