Imported from dimagi-internal/ace (
skills/solicitation-create/SKILL.md). Install upstream withnpx skills add dimagi-internal/ace --skill solicitation-create. Copyright stays with the author.
Solicitation Create
Phase 8 default-run skill. Builds and publishes the solicitation in one shot — ACE always publishes, never drafts. The solicitation can be edited post-publish via the labs UI without affecting responses.
See skills/_solicitation-template.md for the shared
phases.solicitation-management.products.solicitation contract and
connect-labs MCP atom inventory.
Per-run solicitations are expected, not a bug
Every /ace:run publishes a FRESH solicitation under the same
Connect program — by design. This skill does NOT detect already-open
solicitations on the program and does NOT close-or-coordinate against
them. Two operational facts make multiple open solicitations correct:
- Solicitations are run-scoped audit trails. Each run's solicitation captures that run's intent (PDD wording, criteria, indicative budget, archetype, deadline). Different runs ship different PDD revisions; merging them under one solicitation would lose the audit trail.
- Launch is operator-coordinated, not skill-coordinated. The
typical opp will only have one solicitation actually launched
to candidate LLOs (the chosen release-candidate run's). The other
open solicitations live in the labs portal until the operator picks
one to drive Phase 9 from. Stale solicitations are
operator-cleaned-up via
connect-labs delete_solicitationor the labs UI when picking a release-candidate run.
Nor is an approaching deadline with zero responses. The same
doctrine, in the shape a TURN meets it rather than a run: a published
solicitation carries a real application_deadline and a real response
count, and an agent reading deadline in N days, 0 responses off the
labs API will reach for "this lapses unless someone acts" and escalate
it to a human. Do not. Zero responses is the CORRECT and expected state
— Phase 8 is publish-only by default and deliberately emails no
candidate LLOs (llo-invite is a no-op without an explicit operator
opt-in), so nobody has been invited and nothing is waiting. A lapsed
run-scoped solicitation costs nothing; it was an audit trail, not a
procurement. Surface it ONLY if an operator has already opted into
inviting candidates for that specific run. (Origin: 2026-09-04/05 — a
turn raised solicitation 17609's "deadline 12 Sep, zero responses" as a
decision needing a human three times across two days on
hh-poverty-targeting, a test opp. Jon: "stop talking about the
solicitation, this is all just test work and its fine." The doctrine
below already said these are audit trails; what was missing was that the
deadline and the response count are audit-trail fields too.)
If you (the agent reading this skill) notice multiple open
solicitations on the same Connect program and feel the urge to
"deduplicate" or "warn-and-prompt," resist. It's not a footgun; it's
how per-run independence is meant to look at the solicitation surface.
The same pattern applies to per-run Connect opportunities and OCS
chatbots — see agents/ace-orchestrator.md § Modes for the broader
"each run gets its own live entity; stale ones are operator-managed"
contract.
Inputs
ACE/<opp-name>/runs/<run-id>/1-design/pdd-to-work-order.gdoc— primary content source. The Phase 1 work order is the comprehensive, opinionated program brief: scope (will / will not), per-unit verification criteria, roles + RACI, reporting cadence, ethics scope, data-handling, payment schedule, timeline. This skill transforms the work order into a public-facing solicitation: same comprehensive explanation, less prescriptive (rates become ranges, exact weeks become windows), with the LLO-evaluation framing layered on top.ACE/<opp-name>/runs/<run-id>/decisions.yaml— run-level decisions log. Phase 1'spdd-to-work-orderwrites initialwo-*rows; later phases may add or amend decisions (e.g. operator overrides at gate reviews, Phase 4 budget reductions, archetype-specific clarifications). Read every row before composing — open/closed decisions that affect scope, payment band, geographic scope, or ethics must be reflected in the published solicitation.ACE/<opp-name>/runs/<run-id>/1-design/idea-to-pdd.md— the approved PDD. Source of truth forarchetype, the problem-statement / why-this- matters narrative (often more vivid than the work-order's contractual framing), and the geographic scope. Used to write the solicitation's opening — the foundation-pitch context an LLO needs before the work- order-derived scope makes sense.ACE/<opp-name>/opp.yaml—connect.program.id(Connect UUID), opp display name, organization_slug, optional cachedconnect.program.connect_int_id(ConnectProd's integer program id — ConnectProd predates UUIDs and exposes both; the labs/solicitation surfaces key off this integer. NOT a labs-minted id).ACE/<opp-name>/runs/<run-id>/4-connect/connect-program-setup.md— the Connect program name (used to resolve the ConnectProd integer program id vialabs_contextas a fallback, whenconnect_int_idwasn't captured at program-create time; see Step 5).ACE/<opp-name>/runs/<run-id>/4-connect/connect-opp-setup.md(optional but recommended) — the current run's Connect opportunity identifiers, payment unit, start/end dates as actually configured. When present these override PDD defaults (the work order's payment band may have been adjusted at Phase 4 if the program budget was capped — that adjusted band is the truth for this solicitation).
Products
8-solicitation-management/solicitation-create_draft.md— the composed payload pre-publish (audit trail for what was proposed)8-solicitation-management/solicitation-create_published.md— solicitation_id, public_url, manage_url, deadline, criteria as publishedrun_state.yaml.phases.solicitation-management.products.solicitationblock populated (id, public_url, deadline, status: open, labs_program_id, connect_program_id, connect_opportunity_id). Per-run only — each run of the opp publishes a fresh solicitation. Operator-cleaned-up when picking a release-candidate run.
Process
Design principle: ACE owns composition; labs validates the schema. The labs MCP's
create_solicitation/update_solicitationtools validate the payload against labs's canonical solicitation schema (the same schemasolicitations/models.py's @property accessors read and the public-detail template renders) and reject drift withINVALID_SCHEMA+ per-field error details undererror.details.fields. Labs does NOT compose content — there is nocreate_solicitation_from_brieftool, no "standard 6 questions" server-side, no labs-side AI agent that this skill defers to. ACE owns the entire compose-the-content path: voice, archetype branching, scope-of-work transformation from the work order, question framings, evaluationscoring_guides, decisions-log integration. The labs contract is structural enforcement, not content delegation.Operational consequence: when labs surfaces an
INVALID_SCHEMAerror on publish, readerror.details.fieldsto find the offending field, fix the composition in this skill (or in the calling agent's prompt if the issue is content-shape vs schema), and retry. Do NOT work around schema errors by writing top-level fields outside the validated set — those fields silently drop at the labs persistence layer even when validation lets them through.Before each publish, re-read labs's canonical inputSchema via
tools/listif the SKILL.md hasn't been refreshed against a recent labs deploy. The schema is the source of truth for the field shape; this SKILL.md mirrors it.
Design principle: per-unit payment is negotiated, not declared. The labs solicitation
dataschema deliberately has noper_unit_paymentstructured field — and we should not push for one. Per-unit payment shape varies by archetype (per-visit / per-session / per-stage) and within each archetype the right rate is opp-and-LLO-specific. Solicitations express payment as a range with rationale inscope_of_workprose (e.g. "Per verified session: 80–120 USD-equivalent, facilitator + notetaker combined") and thequestionsblock asks the responding LLO to propose their actual rate + why (q6 in the default template). The awarded LLO's proposed rate becomes theconnect.deliver_unitpayment_unit amount at Phase 4 setup time. Do not embed a fixed per-unit number as the load-bearing economic; the range + the question + the LLO's response are the load-bearing parts.
-
Read all source materials. Open in this order:
- Work order (
1-design/pdd-to-work-order.gdoc) viadocs_get— this is the primary content source. Pull the full body, including all sub-sections: Scope of Work (will / will not), Verification of Verified Units, Roles + RACI, Reporting, Ethics, Data Handling, Payment Terms (with the payment schedule sub-table), Timeline. - PDD (
1-design/idea-to-pdd.md) viadrive_read_file— forarchetype, the Problem Statement (the vivid "why malaria, why now, why this approach" narrative), the Intervention Design overview, the Target Population framing, and Geographic Scope. - decisions.yaml (
<run-folder>/decisions.yaml) — every row. Pay special attention to:status: openrows on payment / scope / language / ethics — these are explicit operator deferrals that should surface in the solicitation (typically by phrasing the relevant scope item as a band + asking the LLO to propose within it).- Rows tagged
phase: 4-connector later that AMEND a Phase 1 decision (e.g. budget cap was reduced at Phase 4 — the work order's NTE is stale; reflect the Phase 4 reality).
- opp.yaml for opp identity + program reference.
- Phase 4 outputs (
4-connect/connect-program-setup.md,4-connect/connect-opp-setup.md) for the labs program name + the run's Connect opportunity identifiers + actual payment unit configuration. The Phase 4 payment band overrides the work order's if they differ.
- Work order (
-
Build the solicitation payload using the labs canonical schema. The labs MCP's
tools/listis the canonical contract — it returns the liveinputSchemaforcreate_solicitationandupdate_solicitation. If this SKILL.md ever disagrees withtools/list,tools/listwins; re-read it and update the skill. See § Stale-schema gotcha below for why this matters.The deployed schema is flat — every solicitation field is a top-level property of the atom's argument object. There is no
data: {...}envelope. The canonical shape is:data-object field Type Source / composition titlestring Work order title, stripped of "Work Order —" prefix; e.g. "Connect ITN SBC Exploration — Barrier Diagnosis in Malaria-Endemic Households" solicitation_typelowercase "eoi"or"rfp"PDD ## Solicitation→Solicitation type(defaulteoi). Must be lowercase — the labs template literal-comparessolicitation_type == 'eoi';'EOI'renders as a fallback badge.descriptionstring (markdown) Comprehensive, 500-800 words. Opens with the PDD's problem framing (why this matters, what the gap is), transitions into what this opportunity does and why this approach, closes with what the dataset/output enables downstream. Foundation-pitch tone, not procurement-form. Not a one-paragraph summary. scope_of_workstring (markdown) Comprehensive, 600-1000+ words of structured markdown. Derived from the work-order body. See § Scope-of-work composition below for the exact section mapping + de-prescription rules. Must be a single markdown string with ##sub-headings and-bullets, NOT a JSON array (the labs template runs it through a markdown filter; an array string-coerces to Python repr).application_deadlinestring YYYY-MM-DD`(now() + (response_window_days expected_start_datestring YYYY-MM-DDAnchored to the deadline, not to the Phase 4 opp row. Must be strictly AFTER application_deadline:application_deadline + a contracting allowance(≥ 7 days; ~2 weeks is the ACE default — it has to cover response review, award, and contracting). Use the Phase 4 oppstart_date(or a PDD## Timelinestart) ONLY when that date already satisfies> application_deadline; otherwise derive it from the deadline and record the derivation in the draft + adecisions.yamlrow. NOTanticipated_start.expected_end_datestring YYYY-MM-DDexpected_start_date+ the PDD's POST-AWARD duration — NOT its whole## Timelinetotal. The row above putsexpected_start_dateafter the solicitation window, so adding the full total re-spends time the start date has already consumed. Compute the addend as the sum of the## Timelinerows describing work performed AFTER award — equivalently, the total less every row that has already ended byexpected_start_date. Two rules for deciding, in order: (1) solicitation-open row(s) are ALWAYS subtracted — they end atapplication_deadlineby construction. (2) An award / contracting row is subtracted only where it is purely contractual; where it also carries LLO delivery work (onboarding, worker registration, Learn setup), keep it — that work happens after award and the contracting allowance does not perform it. Where the PDD states only a total with no per-row breakdown, subtract the solicitation window actually used (application_deadline − publish date). Take the top of a band (## Timeline"~9–11 weeks" → 11 weeks, then subtract). Must be strictly afterexpected_start_date, and Step 7a bounds the span. Record the subtraction — which rows were dropped, which were kept and why, and the resulting week count — insolicitation-create_draft.mdand in theresponse-deadlinedecisions.yamlrow. Use the Phase 4 oppend_dateonly when the Phase 4 dates were adopted wholesale under the rule above. NOTanticipated_end.estimated_scalestring Human-readable summary of expected reach, e.g. "30–50 verified HH visits per LLO; 2–3 LLOs total (90–150 HH end-to-end)". Sourced from PDD ## Target Population→Expected reach. NOTsample_target.contact_emailstring (optional — omit entirely when not derivable) Point-of-contact shown on the published solicitation (candidate questions + responses land here). Source it ONLY from the PDD — set it when the PDD explicitly names a point-of-contact / program email (e.g. a ## Solicitationcontact line or a clearly-stated coordinating email). If the PDD does not make the contact email obvious, OMIT thecontact_emailfield entirely — do not send it, do not fall back to any env var or the shared ACE bot inbox.contact_emailis optional in the labscreate_solicitationschema (verified via livetools/list:requiredis justtitle/description/solicitation_type), so a solicitation with no contact email publishes cleanly and simply renders without a contact line. Emit a one-line[INFO]when omitting (no PDD-derived contact email; publishing without a contact line) — NOT a[WARN], since a missing contact on a public listing is expected, not a defect. Never invent an email or reuse a bot inbox as a stand-in: a wrong contact on a public listing routes real candidate questions to a mailbox nobody reads. (Supersedes the old${ACE_SOLICITATION_CONTACT_EMAIL}→${ACE_GMAIL_ACCOUNT}fallback; the env var was removed because the shared bot inbox was never a real point of contact. The hard-[BLOCKER]-on-unset was already removed in jjackson/ace#636.)evaluation_criteriaarray of {id, name, weight, description, scoring_guide, linked_questions}Composed locally — see Step 3. NOT rubric. NOT[{dimension, criterion, weight}]. The deployed schema requiresid,name,weight(verified via livetools/list); the ACE convention addsdescription,scoring_guide, andlinked_questionsas content-quality requirements.idis kebab-case (e.g.field-ops-realism), unique within the rubric — deriveslugify(name)if you're tempted to elide it; explicit ids are required because duplicate-named criteria would otherwise silently collide. Weights sum to 100 (integers).questionsarray of {id, text, type, framing, required, options}Composed locally — see Step 4. NOT response_questions. Field istext, notquestion. The deployed schema requiresid,text,type(verified via livetools/list);framingis an optional structured key that ACE always populates because it's the rubric anchorsolicitation-reviewconsumes when scoring responses.idis kebab-case (e.g.field-ops-realism).typeis one of"textarea"(default — use it for any prose answer),"text"(a single-line input; the right choice for a short factual answer like an org name, a headcount or a date),"multiple_choice", or"number". Verified against the livetools/list2026-08-29: the enum is exactly[multiple_choice, number, text, textarea](ace#1834 —texthad been omitted here). Emptyframingis a[BLOCKER]— the review path can't score responses against a missing anchor.statusstring 'active'(publishes immediately;'draft'for dry-run mode).is_publicbool true— lists it on the labs marketplace for logged-in labs users. It is NOT anonymous readability:/solicitations/<id>/requires a labs login by design (see § Step 7a), so an org without a labs account cannot reach the listing at all. Set ittrueregardless; just never describe the result as "public" to a candidate.connect_opportunity_idint Phase 4 opp internal id (not the UUID). Stored on the record for downstream solicitation-review linkage. Why the Phase 4 opp dates are not the source of truth for these two fields. The Connect opportunity's
start_dateis an artefact of when the Phase 4 row was created, not a program commitment. Phase 4 runs before Phase 8 in the same/ace:run, so that date is always at or before the publish date whileapplication_deadlineis publish + ~14 days — copying it publishes a start date roughly two weeks before applications close, on a public partner-facing page. Nor does the PDD fallback rescue it: for any opp whose timeline is relative-to-award (the normal shape for a solicited engagement — the PDD gives only stage durations and the work order says "N weeks from execution, dates set at award"), there is no correct absolute start date to inherit at all. Derive it from the deadline. Step 7a asserts the ordering.Why the end date's ADDEND is scoped, with the arithmetic worked through. The same trap one field to the right: a PDD
## Timelinefor a solicited engagement normally starts its clock at solicitation-open, not at award, so its total already contains the windowapplication_deadlineconsumed. Adding that total toexpected_start_datespends it twice. Worked example —bednet-check-2-visit/20260828-0629(labs solicitation 17695, program 231), PDD## Timeline:Row Duration Subtract? Solicitation open 2 weeks yes — rule (1); it ends at application_deadlineAward, onboarding, worker registration 1 week no — rule (2); onboarding + worker registration is LLO work performed after award, which the contracting allowance does not do Learn completion 1 week no Field work ~13 weeks no Layer C audit and closeout 1 week no Total ~18 weeks addend = 16 weeks Published 2026-08-30 →
application_deadline2026-09-13 →expected_start_date2026-09-27 (deadline + 14d).- Whole total:
2026-09-27 + 18 weeks= 2027-01-31 — wrong; the 2-week solicitation window is counted twice. - Post-award addend (1 + 1 + 13 + 1 = 16 weeks):
2026-09-27 + 16 weeks= 2027-01-17 — correct, and what that run published after deviating deliberately.
The 14-day error was invisible: Step 7a asserted only
expected_end_date > expected_start_date, which an over-long date satisfies. Step 7a now also bounds the span (dimagi-internal/ace#1858).templates/pdd-template.mddoes not pin the## Timelineconvention, so whether a given PDD's total starts at solicitation-open is a per-PDD fact you must READ, not assume. Where the rows are labelled, subtract by row; where only a total is given, subtract the solicitation window actually used. Either way, state the subtraction in the draft.Fields ACE used to write that are NOT in the labs canonical schema and MUST be removed from the payload:
overview(usedescription)response_window_days(computeapplication_deadlineinstead)anticipated_start/anticipated_end(useexpected_*)sample_target(useestimated_scale)rubric(useevaluation_criteria)response_questions(usequestions)pass_bar,eligibility_criteria,geographic_scope,per_hh_payment_band_usd— these aren't rendered by the public- detail template. Roll their content intodescription/scope_of_workprose instead. Adding new top-level fields to the payload that aren't insolicitations/models.py's @property accessors silently does nothing — they sit in the JSON blob unread.
is_public: trueflips the server-side public ACL flag (the field the/solicitations/marketplace query actually filters on). That means the title, description, scope_of_work, and questions become readable by any unauthenticated visitor. Before callingcreate_solicitation, scan the composed payload and confirm:descriptioncontains no names, dates of birth, phone numbers, addresses, or health datascope_of_workreferences the LLO target population in aggregate terms (e.g. "households in Kerala") rather than naming specific people, facilities, or identifiable program participantsquestionsask for capability self-disclosure, not for PII
If the PDD body itself contains PII that would propagate into the solicitation, halt and surface a
[BLOCKER]naming the offending field — do NOT publish a redacted version silently, because the PDD is the operator's source of truth and they need to know it needs scrubbing.Scope-of-work composition — derive from the work order, NOT from PDD-section concatenation. The work order is the comprehensive, opinionated program brief; this skill transforms it into a public- facing scope. The transform is:
-
Pull the work-order sections directly. Map work-order sub-sections to scope_of_work
##sub-headings in the markdown output:Work-order section Scope-of-work ##heading§2 Scope of Work (will) ## What we're asking the LLO to do§2 Scope of Work (will not) ## What is NOT in scope§3 Roles + RACI ## Roles & responsibilities§4.1 Verified Unit ## What counts as a verified unit§4.2 Verification criteria ## Verification & quality bar§4.3 Reporting cadence ## Reporting cadence§5 Payment Terms (+ schedule sub-table) ## Payment structure(de-prescribed — see below)§6 Timeline ## Indicative timeline§7 Ethics scope ## Ethics & compliance§8.1 Permissions / data handling ## Data handling -
De-prescribe contractual specifics during the transform:
- Exact dollar amounts → ranges with rationale. The work order
says "USD $1,800 total NTE, $10/HH"; the solicitation says
"Per verified HH visit: USD $8–15 band; LLO proposes the exact
rate in their response with regional cost-of-living
justification." The PDD's payment-band block (§ FLW Requirements
→
Per-visit payment rate band) is the canonical source for the range. - Exact start/end dates → month windows in the prose body
(the structured
expected_start_date/expected_end_datefields carry the exact dates separately). E.g. "Field weeks start in early June 2026; closeout end of July 2026." - Specific calendar weeks → relative windows. "Week 4 launch / Week 6 checkpoint" → "approximately 3 weeks after award (launch); approximately 5 weeks after award (mid-pilot checkpoint)."
- Specific tooling versions or build IDs → omit entirely (the LLO doesn't pick those).
- Operator-internal language (CCC ticket numbers, ACE skill names, run-id references) → omit entirely.
- Exact dollar amounts → ranges with rationale. The work order
says "USD $1,800 total NTE, $10/HH"; the solicitation says
"Per verified HH visit: USD $8–15 band; LLO proposes the exact
rate in their response with regional cost-of-living
justification." The PDD's payment-band block (§ FLW Requirements
→
-
Preserve all explanatory framing. The work-order's "why this verification rule," "why this reporting cadence," "why this ethics scope" prose is exactly what an LLO needs to understand what they're committing to. Do NOT compress it. If a paragraph explains the rationale for a constraint, the paragraph stays in the solicitation.
-
Surface open decisions. For each
status: openrow indecisions.yamlthat affects scope (payment, language, ethics surface, geographic scope, etc.), add a one-sentence note to the relevant##sub-section noting the deferral and pointing to the matching question (e.g. "Working language(s) are LLO-proposed — see Q5 in the response template").
Length target: 600-1000+ words. A scope-of-work shorter than 500 words is a signal that the work-order content was over-compressed; re-expand before publishing.
Format invariant: single markdown string,
##sub-headings between sections,-bullets for enumerated items. Never an array of strings (the labs template will string-coerce the array to Python list repr and render['item1', 'item2', ...]literally — verified live on solicitation 3130, jjackson/ace bug surfaced 2026-05-21).Description composition — same comprehensive treatment, separate target:
- Open with the problem framing from the PDD's
## Problem Statement. Lead with the real-world stakes (malaria deaths, the access-vs-use gap, what the literature does NOT yet localize). - Bridge to what this opportunity does — the exploration framing, why exploration before intervention, what's intentionally NOT measured here (e.g. intervention effect — that's a later opportunity).
- Close with what the dataset/output enables downstream (the named downstream consumer if one exists — e.g. GiveWell EOI barrier- diagnosis section — but written in plain terms an LLO can evaluate, not as procurement jargon).
- Foundation-pitch tone, not procurement-form tone. The LLO is a potential partner deciding whether the program is one they want to be part of; the description has to sell that, not just list facts.
- 500-800 words. A description shorter than 300 words is a signal the PDD framing was under-extracted; re-expand.
- Whole total:
-
Compose evaluation criteria locally. Read the PDD's archetype, intervention summary, success criteria, and the work-order's verification + RACI sections. Draft a structured rubric inline using the same archetype-aware judgment that
solicitation-create-evalwould apply.Required shape per criterion:
- id: string # kebab-case identifier, e.g. "field-ops-realism" — REQUIRED name: string # short title, e.g. "Field operations realism" description: string # 1-2 sentence explanation of what this measures weight: int # 5-30, integer; all criteria weights sum to 100 scoring_guide: string # what makes a 10/10 vs 5/10 vs 0/10 — concrete and falsifiable linked_questions: [string] # one or more question `id`s from the questions block; each criterion links to ≥1 questionid,name,weightare required by the labs schema.description,scoring_guide, andlinked_questionsare ACE content-quality requirements layered on top.idis kebab-case and unique within the rubric — deriveslugify(name)if needed, but emit it explicitly (auto-derivation would silently collide on duplicate names).scoring_guideandlinked_questionsMUST be non-empty; an emptyscoring_guidemakes the rubric uninterpretable to responding LLOs and silently ships an unscoreable solicitation. Weights must sum to 100 (integers — the labs template formats them as<weight>%). 5-8 criteria total; more than 8 dilutes the signal, fewer than 4 misses dimensions.scoring_guideshape — what a strong response looks like: Eachscoring_guideMUST describe (a) what a strong (8-10) answer looks like — concrete, falsifiable signals; (b) what a weak (3-5) answer looks like — common shortcuts or vague phrasing; (c) what counts as 0 — missing or refused. Example for a "Field operations realism" dimension:Strong (8-10): week-by-week schedule names supervisor:FLW ratio, mid-pilot checkpoint participation is explicit, photo-heavy visit logistics (storage, upload bandwidth) addressed concretely with named owners. Mid (5-7): schedule present but supervision model thin; logistics gestured at. Weak (1-4): generic timeline, no supervisor-ratio discussed, logistics unaddressed. Zero: no schedule provided.
atomic-visit(4-axis starter): FLW deployment scale, geographic-fit, supervision model, data-quality track record.focus-group(6-axis starter — research-stage opps need deeper rubric than CHW-deployment opps):- Qualitative-research experience (weight ~0.20) — prior FGD or in-depth-interview engagements; ability to produce usable session-level qualitative content.
- Facilitator skill & language fit (weight ~0.20) — named facilitators with matching local-language fluency + 2+ years community-research experience.
- Homogeneous-group recruitment (weight ~0.15) — ability to recruit separate mother / father / grandmother groups in the same community without selection bias toward LLO-program-favored families.
- Coordinator capacity for gdoc review (weight ~0.15) — rolling-basis review of facilitator gdocs against the PDD's Output Specification.
- Audio handling out-of-band (weight ~0.10) — minimum-45-min audio capture, secure Drive-based storage, consent-decline fallback to notetaker-only.
- Timeline + per-session payment economics (weight ~0.20) — ability to field within window, comfortable with per-attestation-form-submission payment structure.
multi-stage: emphasize stage-gate discipline, archetype fluency across stages, transition-management. For each stage with its own archetype, fold in 2-3 axes from that archetype's starter.
Note (0.13.3): the earlier 0.12.0 SKILL.md called
mcp__connect-labs__generate_criteriahere. That atom does not exist in the labs MCP today (the underlying/api/generate-criteria/HTTP endpoint exists but isn't surfaced as a tool). When labs does expose it, we can swap this local composition step for an MCP call without changing the rest of the skill.Question composition — every question MUST have a
framingfield (1-2 sentences explaining why we're asking) AND atextfield (the actual prompt). The framing field is what surfaces the "what makes a strong response" intent to the LLO without making them guess.Required shape per question:
- id: string # kebab-case, e.g. "field-ops-realism" — REQUIRED by labs schema text: string # the actual prompt — REQUIRED by labs schema type: string # "textarea" (default) | "text" | "multiple_choice" | "number" — REQUIRED by labs schema framing: string # 1-2 sentence "why we're asking" preface — optional in labs schema, ALWAYS populated by ACE required: bool # default true (optional in labs schema) options: [string] # required when type=multiple_choiceLabs's public-detail template renders
framingabove the prompt in a muted "Why we're asking" preface block;solicitation-reviewconsumesframingdirectly as the rubric anchor for response scoring. Example:- id: field-ops-realism framing: | A strong response names supervisor:FLW ratios, handles the photo-heavy visit logistics, and treats the mid-pilot checkpoint as a real planning anchor. text: | Propose a week-by-week schedule from award through Week 10 closeout, including LLO mobilization, Connect onboarding, Learn calibration, field launch, mid-pilot checkpoint, and closeout. required: true type: textareaEmpty
framingis a[BLOCKER]—solicitation-reviewcan't score responses against a missing anchor.Dedupe by intent. The PDD's
## Solicitation→Response templatemay list opp-specific questions that overlap with the default set. Combine them — never publish two questions that ask the same thing in different wording (e.g. don't ask about "language capacity" AND "language + translation effort" as separate questions — fold them into one prompt with framing that calls out both axes).Length budget: 7-9 questions total. Fewer than 6 leaves evaluation criteria un-linkable; more than 10 fatigues the LLO and produces shallower answers across the board.
Default 6-question response template, archetype-branched (used as the starting set; merge in PDD overrides per the dedup rule above):
For every archetype except
focus-group(atomic-visit,multi-stage,longitudinal-visits, …) — the CHW-deployment vocabulary is the default; onlyfocus-groupswaps it out (ace#1691):- Describe your prior experience deploying CHW programs in this archetype.
- How will you recruit and train FLWs for this scope?
- What is your timeline for fielding once awarded?
- What is your supervision model?
- Do you have local-language capacity matching the target geography?
- Provide a budget breakdown for the proposed scope.
For
focus-group(CHW-deployment vocabulary is wrong; swap to qualitative-research vocabulary):- Describe your prior qualitative-research experience (FGDs, in-depth interviews) — topic, segment counts, working language, and what synthesis output you produced.
- How will you recruit homogeneous mother / father / grandmother groups without overweighting households with prior LLO program history?
- What is your timeline from award to first practice-session-pass certification, and from award to first live FGD?
- Describe your coordinator capacity to review facilitator gdocs against an Output Specification rubric on a rolling basis.
- What local-language fluency do your named facilitators have, and what audio-recording equipment do you have available?
- Provide a per-session budget breakdown (facilitator + notetaker + participant compensation + venue + coordinator review amortized).
-
Write the draft for traceability. Save the full payload + the AI-derived rubric to:
ACE/<opp-name>/runs/<run-id>/8-solicitation-management/solicitation-create_draft.mdInclude all fields from the payload as a structured YAML-frontmatter + prose body, so the
solicitation-create-evalrubric can re-read it. -
Resolve the labs program_id (integer). The labs MCP expects the labs integer program ID, not the Connect program UUID. Despite the schema's
program_id: string, labsint()-parses it internally and rejects UUIDs withValueError: invalid literal for int().Resolve in this order:
- Fast path: if
opp.yaml.connect.program.connect_int_idis set (cached at program-create time byconnect-program-setup, or backfilled by a priorsolicitation-createrun), use it directly. This is the durable opp-level cache. - Lookup: call
mcp__connect-labs__labs_context(). Find the organization byopp.yaml.organization_slug(defaultai-demo-space); within it, find the program whosenamematches the Connect program name fromruns/<run-id>/4-connect/connect-program-setup.md(the markdown summary written byconnect-program-setup). Capture the program's integerid. - Cache: write the result to
opp.yaml.connect.program.connect_int_idviaupdate_yaml_file(merge: 'deep'— a partial patch ofconnect.program;two-levelwould replace theprogramsub-object wholesale and drop the existingprogram.id/program.url). This is opp-level state (the program is reused across runs, so its ConnectProd integer id is also opp-level). Also carry the value into this run'sphases.solicitation-management.products.solicitation.labs_program_idvia Step 9's consolidated write so the run state is self-contained. - Halt with a
[BLOCKER]if no name match — likely the Connect program exists but was never mirrored to labs (labs creates shadow programs on first opportunity sync). Surface the Connect program name and the list of labs programs the caller can see.
- Fast path: if
-
Publish. Call
mcp__connect-labs__create_solicitationwith the flat-fields shape from § Step 2 (every solicitation field as a top-level property — nodata: {...}envelope). Labs validates the canonical schema server-side and rejects drift withINVALID_SCHEMA+ per-fielderror.details.fields(JSON-path keyed). Read the error and fix the composition; do not retry with the same payload, and do not stuff extras into a free-form field.Stale-schema gotcha. If you see
INVALID_SCHEMAerrors that reference fields you swear are valid per the labs canonical schema (or vice versa — a payload that should be wrapped works when sent flat, or fields you expected to be optional are flagged required), the MCP subprocess in this session is probably holding a staletools/listview of the labs schema. The labs MCP schema can evolve faster than this skill's documentation, and the proxy's schema gets cached at MCP-subprocess startup. Recovery:- Curl the live
tools/listdirectly:
Treat the response as truth. If it disagrees with this SKILL.md, this SKILL.md is wrong — re-read the schema and update.curl -sS -X POST https://labs.connect.dimagi.com/mcp/ \ -H "Authorization: Bearer $LABS_MCP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' - Restart Claude Code (full process restart, not
/reload-plugins) so the MCP subprocess re-fetchestools/listat startup. - Update this SKILL.md if the live schema has moved.
Call:
mcp__connect-labs__create_solicitation( program_id: <resolved labs_program_id as string>, title: ..., solicitation_type: 'eoi', # lowercase description: ..., # markdown string, 500-800 words scope_of_work: ..., # markdown string, 600-1000+ words, NOT an array application_deadline: 'YYYY-MM-DD', # date string, NOT response_window_days expected_start_date: 'YYYY-MM-DD', expected_end_date: 'YYYY-MM-DD', estimated_scale: 'human-readable string', contact_email: ..., # OPTIONAL — include ONLY if the PDD names a contact; else OMIT this line entirely (no env fallback, no bot inbox) evaluation_criteria: [ {id, name, weight, description, scoring_guide, linked_questions: [qid, ...]}, ... ], # weights sum to 100; id is REQUIRED questions: [ {id, text, type, framing, required}, ... ], # 7-9 items; framing is structured, not inlined in text status: 'active', is_public: true, connect_opportunity_id: <int>, )All solicitation fields are flat at the top of the atom's argument object. There is no
data: {...}envelope. Verified against the livetools/list: top-leveladditionalProperties: false, nodataproperty declared.Do NOT include
overview,response_window_days,anticipated_start,anticipated_end,sample_target,rubric,response_questions,pass_bar,eligibility_criteria,geographic_scope,per_hh_payment_band_usd, orbudget— none of these are read by the labs public-detail template. After labs's 2026-05-22 deploy, the MCP itself rejects unknown top-level fields withINVALID_SCHEMA+ per-field error details undererror.details.fields(JSON-path keyed, e.g.evaluation_criteria[0].linked_questions). Read the error and fix the composition; do NOT retry with the same payload, and do NOT work around schema rejections by stuffing extras into a free-form field. Step 7a'sget_solicitationround-trip remains the structural double-check.Capture the returned
id(the labs record id) intosolicitation_id. The response also includesexperiment(echo of the program_id) and the data object as written. Public URL pattern:${LABS_BASE_URL}/solicitations/<id>/. Manage URL pattern:${LABS_BASE_URL}/solicitations/<id>/edit/. NO/labs/prefix —connect-labs/config/urls.pymounts the solicitations app at/solicitations/, not/labs/solicitations/. The/labs/prefix is reserved for the authenticated Labs UI (overview, login, explorer).Check the route exists before recording the URL — expect a 302, NOT a 200. After publish, issue a HEAD against the constructed public URL. The labs detail page requires a labs login by design (§ Step 7a explains why
is_publicis not anonymous readability), so a correctly-published solicitation answers 302 →/labs/login/. Measured on solicitation 14796 (bednet-check-2-visit/20260817-1720):/solicitations/14796/→302 → https://labs.connect.dimagi.com/labs/login/?next=/solicitations/14796/.The signal here is 404, not non-200. A 404 means the URL pattern doesn't match the current labs URLconf (regression on labs side) — surface that as a
[BLOCKER]rather than writing a broken URL intorun_state.yaml. Any 2xx/3xx means the route resolves; do NOT[BLOCKER]on the 302, which is the expected response for every solicitation ACE publishes. Whether the record'spublicflag actually flipped is settled by Step 7a'sget_solicitationround-trip, not by this probe — an anonymous HTTP client never gets far enough to see it. - Curl the live
7a. Verify the round-trip via get_solicitation. Immediately after
publish (whether via MCP atom or direct JSON-RPC fallback), call
mcp__connect-labs__get_solicitation(solicitation_id=<returned>, program_id=<labs_program_id>) and assert every load-bearing field
round-trips intact. Treat the response as the canonical view of
what labs persisted — if it diverges from what you sent, that's
either silent-drop (an extra field labs rejected without surfacing)
or silent-mutation (labs reformatting something).
Required round-trip assertions:
titlematches what was sent.descriptionlength is non-empty and ≥ 80% of the sent length (small whitespace/markdown normalization is OK; 20%+ shrinkage is silent-drop).scope_of_workis a string (not an array) AND length ≥ 80% of sent.application_deadlineparses asYYYY-MM-DDAND matches the computed deadline.expected_start_date+expected_end_dateboth parse and match.- Ordering:
expected_start_date > application_deadlineANDexpected_end_date > expected_start_date. A violation is a[BLOCKER]— it is not a round-trip miss but an incoherent payload: the published page would tell candidate LLOs the work starts before their application is even due. Recompute per Step 2 and re-publish viaupdate_solicitationbefore writingpublished.md. - Engagement span (ace#1858):
(expected_end_date − expected_start_date)must be ≤ the PDD's## Timelinetotal − the solicitation window actually used (application_deadline − publish date). Over-long is a[BLOCKER]: it is the signature of adding the PDD's whole timeline to a start date that already sits past the solicitation window — see the worked example under Step 2. This is a deliberately LOOSE ceiling — it does not additionally subtract any purely-contractual award row — so it catches the whole-total error without demanding the subtraction be exact.> startalone does not catch it: onbednet-check-2-visit/20260828-0629the wrong value was 2027-01-31 against a correct 2027-01-17 and passed the ordering check cleanly. estimated_scaleis non-empty.contact_email: assert only if you sent one — when sent, it matches; when omitted (no PDD-derived contact), assert it is absent/empty on the round-trip (an omitted optional field is correct, not a miss).len(questions)matches sent; every question hasid,text,type,framing(non-empty), plusrequiredif sent.len(evaluation_criteria)matches sent; every criterion hasid,name,weight,scoring_guide,linked_questions(non-empty);sum(weights) == 100.is_public: true;status: 'active';connect_opportunity_idmatches sent.
Any miss → [BLOCKER] naming the missing or mutated field +
the probable cause (e.g. "scope_of_work returned as an array — labs
string→array coerced; check the payload was sent as a single
markdown string and not Python-list-coerced upstream"). Do NOT
proceed to write published.md against a half-persisted solicitation.
Why round-trip and not curl-the-public-URL. The labs
/solicitations/<id>/ detail page requires a labs login by design.
is_public: true controls marketplace listing visibility for
logged-in labs users; it does NOT mean "anonymously readable." A
vanilla curl gets the labs login page, not the solicitation. The
get_solicitation round-trip is the canonical structural check at
the persistence layer. (For visual confirmation of rendered HTML,
log into labs and hit /solicitations/<id>/ in a browser; ACE
skills don't need to script that.)
This catches the class of bugs where the labs MCP accepts the
create cleanly but persisted state diverges from sent payload.
Verified live on solicitation 3130 (malaria-itn-app/20260521-1400)
where all 6 sections were broken simultaneously because the entire
payload schema had drifted — round-trip would have caught it at
write time instead of human-eye time.
7b. Verify the round-trip. Immediately after publish, call:
mcp__connect-labs__get_solicitation(
solicitation_id: <returned id>,
program_id: <same labs_program_id used on create>,
)
This catches the silent-misconfig class where the create succeeds but
the record is unreachable on subsequent reads. Without program_id,
the labs LabsRecord API filters to is_public=true only — so a
newly-created is_public: false solicitation (or any future change in
default visibility) would round-trip as "not found." Always pass
program_id on read so the prod-side membership check authorizes the
private record. If the verification call returns no record or a
different id, halt and surface the mismatch — do not proceed to
write published.md or mutate opp.yaml.
-
Write
published.md. Save:ACE/<opp-name>/runs/<run-id>/8-solicitation-management/solicitation-create_published.mdBody: full payload as written, returned IDs/URLs, deadline in absolute ISO-8601 form, the AI-derived
evaluation_criteria(sosolicitation-reviewandsolicitation-monitorhave the rubric without re-fetching from labs). -
Write the consolidated solicitation outputs block to the current run's
run_state.yaml.phases.solicitation-management.products.solicitationviaupdate_yaml_file+merge: 'deep':phases: solicitation-management: products: solicitation: solicitation_id: <returned> labs_program_id: <integer resolved in step 5> public_url: <returned> manage_url: <returned> type: <EOI|RFP> published_at: <now ISO-8601> deadline: <computed ISO-8601> status: open awarded: response_id: null awarded_at: null awarded_org_slug: null awarded_org_name: null awarded_contact_email: null award_amount: nulldeepmergesproducts.solicitationwhile preserving sibling keys on thesolicitation-managementphase block (status,steps, andproducts.selected_llo) at every depth —two-levelwould replace the whole phase block wholesale (#572/#587). This skill is the sole writer ofproducts.solicitationwithin the run;solicitation-reviewupdates the same block in place at award time (within the same run).selected_llois stubbed bysolicitation-reviewon award, not here.selected_llolives atphases.solicitation-management.products.selected_llo.No write to
opp.yaml.solicitation. Solicitations are per-run — every/ace:runpublishes a fresh solicitation; stale ones from prior runs are operator-cleaned-up when picking a release-candidate run.
Error handling
- Labs MCP unreachable (proxy returns transport error): halt with a
doctor-style message pointing at
/ace:doctor's[Connect Labs]section. create_solicitationreturns 4xx: preservedraft.md, halt, surface the error verbatim. Do not retry — most 4xx is a payload schema mismatch or the program_id is wrong.generate_criteriareturns degenerate output (empty list, single criterion): write what was returned, markevaluation_criteriaasneeds-reviewinpublished.md, still publish. Criteria are editable post-publish via labs UI without losing responses.opp.yaml.program_idmissing: halt with "run Phase 4 (connect-setup) first to register a Connect program." The Connect UUID is the upstream evidence that a labs-side program will exist; without it there is nothing to look up inlabs_context.labs_contextreturns no name match for the Connect program: halt. Surface the Connect program name we tried to match plus the list of labs programs visible under the org. The remediation is usually one of: (a) labs's program shadow was created with a different display name (rename via the labs UI); (b) the Connect program was created in an org the caller can't see in labs_context (PAT scope mismatch); (c) the program was created so recently that labs hasn't synced yet — wait a minute and re-run. Do not publish a solicitation under a guessed labs_program_id.
Output
ACE/<opp-name>/runs/<run-id>/8-solicitation-management/solicitation-create_draft.md(audit)ACE/<opp-name>/runs/<run-id>/8-solicitation-management/solicitation-create_published.md(live state)run_state.yaml.phases.solicitation-management.products.solicitation.{solicitation_id, public_url, deadline, status: open, labs_program_id, ...}populated (per-run only).opp.yaml.connect.program.connect_int_idcached on first resolution (durable opp-level — ConnectProd's integer id for the same program row as the UUID; reused across runs).selected_llois left untouched here (populated bysolicitation-reviewon award atproducts.selected_llo).
MCP Tools Used
connect-labs:labs_context(resolve Connect program name → labs integer program_id, when not cached),create_solicitation,get_solicitation(round-trip verification — passprogram_id)ace-gdrive:drive_create_file,drive_read_file,drive_update_file,update_yaml_file(writephases.solicitation-management.products.solicitationtorun_state.yaml; cacheopp.yaml.connect.program.connect_int_idon first resolution)
Mode Behavior
- Auto: Publish in one pass.
- Review: Pause after Step 6, present
published.mdfor human approval before mutatingrun_state.yaml. (The publish itself already happened — review-mode is about the local state mutation, not the external call. If review rejects, the human can call labs'supdate_solicitationto draft or close the solicitation.) - Dry-run: Steps 1-4, skip steps 5-7. Verdict with
dry_run: true.
Decisions Log
This skill writes load-bearing defaults to the per-run
ACE/<opp-name>/runs/<run-id>/decisions.yaml. The bar criterion and
schema live in skills/idea-to-pdd/SKILL.md § Decisions Log Convention
(canonical authority). The list below catalogs decisions that commonly
qualify under the bar for this phase — a working template, not a
required set. The skill applies the bar criterion and emits whatever
rows meet it; the catalog is a teaching device that improves over time.
Common load-bearing decisions for Phase 8
| ID | Question | Map to surface |
|---|---|---|
solicitation-type |
Which solicitation kind? Options: EOI · RFP · custom |
solicitation-create-eval; affects who applies and at what fidelity |
response-deadline |
Days from publish to deadline (default 14)? | solicitation-create schema; gates Phase 8→9 timing |
response-template-choice |
Stock template vs opp-custom response form? | solicitation-create content; downstream solicitation-review rubric input |
The orchestrator's Phase Write-Back Verifier (agents/ace-orchestrator.md
§ Phase Write-Back Contract § Decisions log clause) enforces the
contract; the renderer (skills/decisions-render) regenerates the gdoc
at end of every phase.
Each row this skill writes uses phase: 8-solicitation-management and
skill: solicitation-create.
Change Log
| Date | Change | Author |
|---|---|---|
| 2026-09-01 | expected_end_date re-spent the solicitation window the start date had already consumed (dimagi-internal/ace#1858). Step 2 said expected_start_date + the PDD's stated duration band, one row below a definition placing expected_start_date after the solicitation window and after award/contracting — while a PDD ## Timeline total for a solicited engagement normally starts its clock at solicitation-open. The two rows therefore double-count the window and the award step. Measured on bednet-check-2-visit/20260828-0629 (labs solicitation 17695, program 231): published 2026-08-30 → deadline 2026-09-13 → start 2026-09-27; the rule applied literally gives +18 weeks = 2027-01-31, against a correct post-award remainder of +16 weeks = 2027-01-17 — 14 days long on a public partner-facing listing. That run deviated deliberately and recorded the derivation in the draft plus the response-deadline decisions row, but a compliant agent would have published the overshoot and nothing would have caught it: Step 7a asserted only expected_end_date > expected_start_date, which an over-long date satisfies. Sibling of ace#1685, which re-anchored the start date in the same change but never scoped the end date's addend. The addend is now explicitly the PDD's post-award duration — total less the rows the deadline + contracting allowance have already passed — with the subtraction recorded in the draft; Step 7a gained a span ceiling (span ≤ total − solicitation window); and templates/pdd-template.md § Timeline now asks the PDD to state where its clock starts, since it pinned no convention and the ambiguity was structural. |
ACE team |
| 2026-08-26 | expected_start_date was derived from the Phase 4 opp row, publishing a start date BEFORE the solicitation's own application_deadline (dimagi-internal/ace#1685). Step 2 said "Phase 4 opp start_date if available", but Phase 4 runs before Phase 8 in the same /ace:run, so that date is always at or before the publish date while the deadline is publish + 14 days. Observed on hh-poverty-targeting/20260824-1404 (labs solicitation 17041, program 189): Phase 4 opp start_date 2026-08-26 vs computed application_deadline 2026-09-09 — following the rule literally publishes a work start 14 days before applications close. That run deviated deliberately (published 2026-09-21 → 2026-12-07, recorded in decisions.yaml row solicitation-date-window), but a compliant agent would have published the contradiction and nothing would have caught it: Step 7a asserted only that the dates round-tripped, and solicitation-create-eval dim 3 graded the deadline in isolation. The PDD fallback does not save it either — for a relative-to-award timeline (the normal shape here) there is no absolute start date to inherit. Both date rows are now anchored to application_deadline + a contracting allowance and the PDD's duration band, with the Phase 4 dates usable only when they already satisfy the ordering; Step 7a gained a [BLOCKER] ordering assertion, and the eval rubric grades the ordering. |
ACE team |
| 2026-08-19 | Deleted two superseded paragraphs that Step 6 still contradicted itself with (dimagi-internal/ace#1523). Surfaced on bednet-check-2-visit/20260817-1720 Phase 8 (labs solicitation 14796). The 2026-05-22 correction below rewrote Step 6's headline guidance but left the retracted prose sitting underneath it, so Step 6 gave opposite instructions on the only two things it exists to specify. (1) A paragraph claiming "the atom requires data (object) … flat top-level fields get dropped by the labs adapter" survived 19 lines below the bolded "All solicitation fields are flat … There is no data: {...} envelope". The flat shape is correct — re-verified against the live tools/list this run: additionalProperties: false, no data property, required: [title, description, solicitation_type] — so a payload built per the stale paragraph sends an unknown top-level key and is rejected INVALID_SCHEMA. Deleted. (2) "Verify reachability … confirm 200 … otherwise [BLOCKER]" contradicted Step 7a's own "Why round-trip and not curl-the-public-URL", which states that the detail page 302s to the labs login by design because is_public is marketplace lis |
Truncated - read the full file at https://github.com/dimagi-internal/ace/blob/61b107aec8678e9cf441952eb9a1c90117170f20/skills/solicitation-create/SKILL.md.