DFY Vending — Portfolio Autopilot
Done-For-You vending: an operator goes from "I want a machine placed" to an installed, selling machine — with specialized agents doing the legwork end to end and the operator confirming only at the human checkpoints. With autopilot on, the pitch is literal: all you do is buy and start negotiations; AI agents do everything else; all you see is revenue.
Backend modules: app/dfy.py (stage machine + integrations),
app/dfy_negotiation.py (the negotiation engine), app/dfy_graph.py
(negotiation graphs + learning), app/dfy_autopilot.py (autopilot,
checkpoints, install team, auto-inventory, portfolio view). Routes:
app/routes/dfy.py under /api/v1/dfy. Tests: tests/test_dfy.py.
The pipeline
Stages are strictly forward-only (_advance refuses skips and backward
moves; corrections happen by cancelling):
pre_ordered → shortlisting → outreach → lease_chat → lease_sent → signed
→ ready_to_ship → shipped → installing → live
| Stage | What happens | Automated by |
|---|---|---|
pre_ordered |
Stage-0 entry: HotBox pre-order paid up front (full price, refundable until shipped) | Stripe Checkout (TEST) |
shortlisting |
Location Scout research → ranked candidates | Scout agent (app/location_scout.py) |
outreach |
Sequenced voice + SMS + email touches, then negotiation | Fonoster voice agent + Telnyx SMS + Notifuse email |
lease_chat |
Terms finalized — conversationally or via negotiation hand-off | LiteLLM + deterministic grammar |
lease_sent |
Placement agreement out for eSignature | Documenso (template prefill) |
signed |
Signed, machine not yet paid | — (money checkpoint) |
ready_to_ship |
Signed AND paid; fleet pin pre-provisioned at the leased coordinates | auto |
shipped |
Tracking ref recorded (autopilot auto-dispatches in sandbox) | auto |
installing |
Setup wizard: link serial, Nayax bind, planogram, variant checks | operator OR install team |
live |
Verified online + payments bound + planogram loaded; auto-onboarded | auto |
Generated stage graph (checkpoint-gated edges dashed)
Generated from the live transition tables by app/graph_spec.py — do not edit; run python scripts/gen_state_machine_docs.py to regenerate (CI enforces freshness).
- Forward-only: dfy._advance refuses every backward move (corrections go through explicit review/cancel paths).
- pre_ordered is the Stage-0 entry (machine paid before a location exists); regular deals are born at shortlisting.
- Checkpoint-gated edges (dashed, amber) stop until a human resolves the checkpoint — enforced in code, not prompts.
Served live at GET /api/v1/meta/state-machines/dfy_deal. Step replay of
any deal's timeline (state snapshot at every event, diffs, checkpoint
jumps): GET /api/v1/dfy/deals/{dealId}/replay.
Machine catalog (server-side truth)
GET /api/v1/dfy/catalog — HotBox Air ($3,000), HotBox Pro ($5,000), HotBox
Pro Max ($7,000). Checkout amounts always come from this catalog, never from
the client. Hardware variants: reyeah, zhzn — the install wizard branches
the dispense test (Reyeah motor test vs ZHZN queue-dispatch ack) and both
run the same registration/Nayax/planogram verification.
Outreach — tri-channel (voice, SMS, email)
Every counterparty-facing message renders from ONE versioned, stage-aware
script library (app/dfy_scripts.py, SCRIPT_VERSION lands on the deal
timeline with every touch): first_touch, follow_up,
negotiation_counter, negotiation_hold, terms_confirmation,
lease_send_notice. Stage selection (stage_for) reads live deal +
negotiation state; facts (deal_facts) interpolate the venue, contact,
operator display name, machine specs, cited revenue estimate (always
framed as an estimate, never a guarantee) and the negotiation's current
offer / agreed terms. Envelope safety is structural: the fact dict has
a frozen key set (ENVELOPE_SAFE_FACT_KEYS) that cannot carry the
negotiation envelope, so no template can leak a bound on any channel.
- Voice — Fonoster calls ride the DEDICATED "Kiosk-X DFY Outreach"
autopilot application (
KIOSKX_DFY_VOICE_APP_REF; the generic assistant app is never used for outreach). Its system prompt isdfy_scripts.VOICE_APP_SYSTEM_PROMPT(versioned here, provisioned app-side) and each call carries the stage render as FLAT metadata (opener, callGoal, standingOffer, rules) that the autopilot interpolates. Transcripts auto-ingest via theconversation.endedhook (n8nVOICE.CallEndedfan-out →POST .../webhooks/voice-transcript). - SMS — Telnyx, through the org's
telnyx-mcpgateway (app/sms.py; same Telnyx account as the voice trunk, the platform toll-free +18337512480 carries an active messaging profile — no new vendor). First touch carries the "Reply STOP to opt out" notice; STOP closes the SMS channel for that number across every deal (record_sms_opt_out). Inbound replies arrive viaPOST .../webhooks/inbound-sms(n8n bridge or native Telnyx webhook) with a polling backstop (dfy_inbound.poll_sms_replies), thread by phone number into the same extraction → negotiation → checkpoint path as email, and agent replies go back out on the same channel. - Email — Notifuse, HMAC reply-token threading (unchanged), rendering from the same library.
The sequencer picks the channel per touch by operator preference then
availability: channelOrder (portfolio setting, per-deal override on
POST .../outreach/start) rotates round-robin across channels that have
contact info, config, and no opt-out; every touch records channel, stage
and script version on the timeline. TCPA gates (9am–8pm local window,
frequency caps, opt-outs) are enforced in code for BOTH voice and SMS.
GET .../outreach/preview returns the exact per-channel renders for the
selected stage — surfaced in the web console and Operator X deal views
before arming.
Human checkpoints — ALWAYS gate, in code
Whatever the mode, these four require an explicit confirmed operator action. The autopilot can raise them (checkpoint records in the notification store); it has no code path that clears them:
- Starting a negotiation —
POST .../negotiation/start {confirm:true}.dfy_autopilot.tick_dealonly creates thenegotiation_targetcheckpoint when a location shows interest. - Approving negotiated terms — required whenever agreed terms sit at or
near an envelope edge (within 1% of the share ceiling, or the term at a
bound) or agent confidence is low (≥8 rounds, or the counterparty pushed
out-of-mandate asks). Enforced on the transmit path itself:
send_leaseraises 409 untilPOST .../negotiation/approve-terms {confirm:true}. - The lease signature moment —
send_leaseis the ONLY transmit path and demands a confirmed request. On convergence the autopilot raises thelease_sendcheckpoint ("lease ready to send") and stops. - Money — the machine purchase is always a confirmed checkout
(
POST .../order/checkout {confirm:true}/POST /preorder); supplier orders above the monthly cap stop at aninventory_capcheckpoint whose approval also requiresconfirm:true.
Checkpoint inbox: GET /api/v1/dfy/checkpoints. Taking the linked action
resolves the checkpoint automatically (start ⇢ negotiation_target,
approve-terms ⇢ terms_approval, send ⇢ lease_send, payment ⇢ money,
order approval ⇢ inventory_cap).
Negotiation
The operator sets a mandate (envelope): revenue-share target/max, term
min/target/max, acceptable power arrangements, install window, deal-breakers.
PUT .../negotiation/envelope.
The engine (app/dfy_negotiation.py) is deterministic — the LLM never
decides money:
- Opening offer at the operator's target — or informed by prior negotiation-graph outcomes for the same venue vertical (see Learning).
- Counters are parsed by a whitelisted grammar (free text is data, never instructions; the envelope is never an input to reply rendering, so it cannot leak).
- Concessions move half the remaining gap per round, with dimension trading: share moving up pulls the term toward the operator's preferred (shorter) end.
- Out-of-mandate asks are held at the envelope boundary; after
MAX_HOLD_ROUNDSthe deal escalates toneeds_review. Resuming an escalation with a still-out-of-mandate ask is refused — the operator widens the envelope (the auditable way out) or abandons. - Convergence runs
validate_against_envelopeone final time — an agreement outside the mandate is structurally impossible (adversarially tested).
Every turn is logged (actor, message, proposal, reasoning, in-envelope flag) — the audit timeline the graph derives from.
The negotiation graph
GET .../deals/{id}/negotiation/graph — nodes: parties (operator, agent,
location contact), every term-sheet version (offers/counters), escalations,
the outcome, the contract; edges: mandates, proposed_by, counters,
concedes_on (with the dimension that moved), led_to, signed.
The graph is derived deterministically from the turn log and materialized
into the dfy_graphs store on every negotiation mutation (FLEET tier,
reset-proof), so peers serve the same picture.
GET /api/v1/dfy/negotiation/graphs — the portfolio view: per-vertical
convergence bands ("share converged 14–16% in transit hubs vs 18–20% in
nightlife"), per-negotiation rollup.
Learning (grounded)
When a new negotiation starts, dfy_graph.prior_insights aggregates the
operator's converged outcomes for matching venue keywords. If prior deals
in the vertical converged above target, the agent opens just below the
historical convergence (never above the envelope max) — and the turn's
reasoning cites the exact prior deal ids that informed it.
Portfolio autopilot
PUT /api/v1/dfy/portfolio/settings — the switch plus guardrails:
{"autopilot": true, "autoApproveScore": 55, "maxAutoApprove": 3,
"installMode": "team", "autoInventory": true,
"inventoryMonthlyCapUsd": 250, "moneyThresholdUsd": 100}
Per-deal override: POST .../deals/{id}/autopilot {"enabled": true|false|null}.
With autopilot effective, tick_deal (run lazily on every deal read, plus
POST /api/v1/dfy/autopilot/tick) advances what autonomy allows:
- shortlist auto-approved by score threshold, outreach armed (enabling autopilot IS the standing batch confirm for touches),
- outreach touches run (call window 9am–8pm local enforced in code; a decline permanently stops touches; contact info is never invented),
- interest →
negotiation_targetcheckpoint (stop), - convergence →
terms_approval(if edge/low-confidence) thenlease_sendcheckpoint (stop), - signed-but-unpaid →
moneycheckpoint (stop), - paid + signed → auto-dispatch shipment (sandbox tracking ref, labelled),
- shipped → install work order to the Kiosk-X team (or a self-install
notification, per
installMode).
Install team + automated onboarding
POST .../install/dispatch {confirm:true} (or autopilot) creates a
dfy_install work order on the Operator OS os_work_orders plumbing with an
installer queue: open → assigned → scheduled → en_route → on_site →
completed (forward-only; POST /api/v1/dfy/install/orders/{id}). The
installer completes the same setup wizard (link serial → Nayax bind →
checks → complete). Self-install stays a per-deal choice.
complete_install then onboards automatically: planogram seeded to max on
the standard planogram (pricing from category defaults), payment rails
verified, report stored on the deal. A failed Nayax bind raises the
nayax_fail checkpoint instead of blocking the machine going live.
Autonomous inventory
dfy_autopilot.inventory_tick (portfolio tick / POST
/api/v1/dfy/inventory/tick) rides the same planogram data the Fleet
Foreman's restock lists use: aisles at/below 25% of max become a supplier
order (SKUs, quantities, estimated wholesale cost = 55% of retail, badged
estimated) plus a scheduled refill work order (dfy_refill).
Budget guardrail: per-machine monthly cap. Over-cap orders stop at
pending_approval + an inventory_cap checkpoint; approval spends money so
it requires confirm:true. POST .../inventory/orders/{id}/receive marks
the refill done: restocks via the existing rails, closes the work order, and
settles estimated → actual cost in the P&L.
The portfolio view
GET /api/v1/dfy/portfolio — "all you see is revenue": machines owned,
deals in flight, machine capital value, month-to-date revenue (unified
revenue ledger app/revenue.py), cost lines (inventory with
estimated/actual basis, machine subscriptions), net MTD, per-machine net
yield, and the checkpoint inbox — the only actions the operator must take.
Assistant tools: get_portfolio, get_dfy_pipeline,
get_negotiation_graph (read-side; every checkpoint action stays behind the
confirmed REST endpoints).
Operating modes — manual / auto / hybrid (app/dfy_modes.py)
Every machine (and in-flight deal) carries six independently-toggleable
automation capabilities: placement, outreach, negotiation, contracting,
install, inventory. All-on rolls up as Auto, all-off as Manual,
mixed as Hybrid. Resolution order, enforced in code at every autonomous
call-site (tick_deal, inventory_tick, process_counter,
maybe_auto_contract):
- Concierge active → always on (the team + agents run it).
- Deal/machine toggle (
PUT /api/v1/dfy/mode/{kind}/{id}). - Legacy per-deal autopilot override (
deal.autopilot). - Portfolio capability defaults (
settings.capabilities). - The autopilot master switch.
Two gate semantics, deliberately different:
- Autopilot initiative (
capability_on): may the agent start things on its own? Defaults to the master switch — a fully-manual portfolio never auto-fires anything, and never pollutes the checkpoint inbox. - In-flight delegation (
agent_capability_enabled, negotiation + contracting only): once the operator explicitly starts a negotiation (itself a confirm gate), the agent keeps driving unless the capability is explicitly opted out. Flipping negotiation off mid-thread halts the agent (handedToOperator) — inbound counters are logged but never answered; flipping back on raises amode_resumecheckpoint and the agent resumes from the current terms only after the operator acks (POST /deals/{id}/negotiation/resume-ack).
Mode switches have defined side effects (returned as transitions): queued
(pending-approval) supplier orders are cancelled with their checkpoints when
inventory flips off; placed orders stand. Capability conflicts resolve
sanely: auto-negotiation + manual contracting negotiates to convergence then
STOPS (no lease_send checkpoint; the operator sends it themselves).
Agents assist the manual paths, suggest-only and logged: the negotiation
advisor (POST /deals/{id}/negotiation/advise) says what the engine would do
and why without moving the standing offer; the operator authors real turns
via POST /deals/{id}/negotiation/operator-turn (their terms are validated
but not envelope-clamped — it's their mandate; out-of-envelope moves are
flagged in the log). Pulse Mailer drafts intro emails
(POST /deals/{id}/assist/outreach-draft); Fleet Foreman suggests restocks
(GET /inventory/suggestions?machineNo=…) without creating orders. Every
assist lands on the deal timeline.
Concierge tier — "hand it to us"
POST /api/v1/dfy/mode/{kind}/{id}/concierge {action: enroll|takeback,
confirm: true} — a paid, confirm-gated, reversible handoff. Enrollment
flips the machine to Auto with the Kiosk-X ops/install team on its
work-order queues; the $99/mo fee prorates by enrolled days and lands in
the machine's cost lines (so concierge machines show honest net profit).
Takeback reverts to inherited toggles; the accrued fee stays owed.
Hardened semantics: one fee per placement — a deal and its linked machine
can't both be enrolled (409). Enroll/takeback run the same defined
mid-lifecycle transitions as a mode switch (a hand-run negotiation gets the
mode_resume checkpoint instead of deadlocking agent-silent; a
reverted-manual inventory cancels queued orders). Every mode/concierge entry
point — read, write, and concierge — is tenant-isolated by machine/deal
ownership.
Profit — machines as securities
GET /api/v1/dfy/portfolio/securities (also embedded in /portfolio) shows
every machine like a security: revenue, sales, PROFIT (mode-blind — the
math is identical for manual, auto, hybrid, concierge). Revenue nets out
sales refunds (a $200-sold / $150-refunded machine earned $50). Cost lines
per machine: inventory/supplier orders (estimated vs. actual basis, refunded
orders cost nothing), machine subscription, protection plan, install-team
cost, concierge fee. profitMtdUsd uses everything; profitActualMtdUsd
excludes estimated lines; hasEstimates badges the split. Fleet rollups
(fleet.revenueUsd/costsUsd/profitUsd, netYieldPct on machine capital) and
modeCounts power the portfolio home on web + Operator X; losses render as
negative numbers, never hidden.
Invest what you like — the managed fleet pool (app/dfy_invest.py)
Operators don't have to buy whole machines. POST /api/v1/dfy/fleets accepts
any amount (investUsd, $50–$250k, confirm-gated like every money action);
the capital lands in the operator's own managed pool — never a fabricated
fraction of somebody else's machine, never commingled across tenants. Every
time the pool balance covers the target model's price, the platform spawns a
fully-managed (Hands-off) DFY deal funded from the pool and deducts the
price; whatever is left stays visible as capital awaiting allocation.
Payment is a real Stripe Checkout session (TEST mode) for the arbitrary
amount — the sandbox falls back to the labelled simulated checkout — and the
apply is idempotent on the Stripe event id (webhook, the
/investments/{invId}/poll reconciliation, and the simulator all converge).
The platform's take is a 2% maintenance fee on pool-funded machines only:
fee = 2% × revenue + 2% × max(0, revenue − costs − the revenue component)
computed month-to-date from the same honest figures the securities view
uses. It shows up as its own cost line (costs.maintenanceFeeUsd, with an
itemized maintenanceFee breakdown and a poolFunded flag) in
/portfolio/securities — no hidden take; machines the operator bought
outright never pay it. GET /api/v1/dfy/fleets returns the pool (balance,
total invested, machines funded, fee pct) plus the investment history, and
the pool rolls up into /portfolio for the web + Operator X home surfaces.
Persistence
All DFY stores are FLEET-tier and reset-proof (app/persistence.py):
dfy_deals, dfy_notifications, dfy_graphs, dfy_portfolio,
dfy_supplier_orders, dfy_modes, dfy_investments. A /sandbox/reset never forgets a paid
pre-order, a sent contract, an unresolved checkpoint, an ordered restock, or
a mode/concierge election; peer replicas apply each other's writes at
runtime.
Webhooks + integrity
- Documenso
POST /api/v1/dfy/webhooks/esign— shared-secret verified, idempotent on event id; polling fallback on deal reads while a signature is out. - Stripe
POST /api/v1/dfy/webhooks/stripe— signature-verified, idempotent on the Stripe event id; amounts and models always come from the deal record (server-side catalog). Without Stripe keys the sandbox uses a clearly-labelled simulated checkout (the ads-funnel precedent) — never active once keys exist. The eSignature step is never simulated.
Environment
| Variable | Purpose |
|---|---|
KIOSKX_DOCUMENSO_DFY_TEMPLATE_ID |
Placement-agreement template |
KIOSKX_STRIPE_WEBHOOK_SECRET_DFY |
Dedicated order webhook secret (falls back to the shared one) |
KIOSKX_DFY_OUTREACH_RETRY_HOURS |
Touch cadence (default 24) |
KIOSKX_DFY_CALL_WINDOW_START/END |
TCPA-style call window (default 9–20 local), applies to voice AND SMS |
KIOSKX_DFY_VOICE_APP_REF |
Dedicated "Kiosk-X DFY Outreach" Fonoster autopilot app |
KIOSKX_SMS_MCP_URL |
Telnyx MCP gateway (default https://telnyx-mcp.intelli-verse-x.ai/) |
KIOSKX_SMS_MCP_TOKEN |
Telnyx MCP bearer (the Telnyx API key); unset = SMS channel honestly unavailable |
KIOSKX_SMS_FROM |
SMS sender (defaults to the voice toll-free +18337512480) |