Atomic Primitives for Voice: Field, Say, and the Power of Plain Strings
There's a moment in every Guava integration where a developer stops and says: "Wait, that's it? Just three types?"
Yes. Just three types.
Field collects structured data. Say delivers deterministic content. A plain Python string hands the LLM a goal and trusts it to achieve that goal naturally. These three primitives, composable in any order, cover the overwhelming majority of what any voice agent needs to do. Understanding exactly what each one does — and more importantly, knowing which one to use when — is the core skill of building with Guava.
Field: Structured Data Collection
Field is the workhorse. Its job is to extract a specific piece of typed information from the caller and store it in the result dictionary. Under the hood, Guava manages the entire collection loop: asking the question, parsing the response, validating the type, reformulating if the response is ambiguous, and moving on only when it has a clean value.
The field_type parameter is doing significant work. It's not just a label — it tells Guava how to parse the response. A date field knows about "next Tuesday" and "the 15th." A currency field knows about "around five hundred" and "five to six hundred dollars." A calendar_slot field knows to invoke the choice_generator and present options.
# Field examples
guava.Field(
key="claim_date",
field_type="date",
description="Date when the incident occurred",
)guava.Field( key="vehicle_year", field_type="integer", description="Year of the vehicle involved", validation=lambda y: 1980 <= y <= 2025, )
guava.Field( key="damage_category", field_type="enum", description="Primary type of damage", choices=["Collision", "Comprehensive", "Liability", "Uninsured motorist"], ) ```
The golden rule for Field: use it whenever you need a value that will be used programmatically after the call. If it's going into a database, an API call, or a downstream system, it should be a Field.
Say: Deterministic Content Delivery
Say is the opposite of Field. Where Field is maximally flexible — it keeps asking until it gets what it needs — Say is maximally rigid. The content you put in a Say will be communicated to the caller. Not paraphrased. Not approximated. Communicated.
This makes Say the right tool for anything that has a compliance or legal dimension: disclosures, terms, warnings, confirmations. It's also right for anything where the exact wording matters to the caller experience — branded greetings, scripted objection responses, closing language.
# Say examples
guava.Say(
"Before we begin, I want to let you know this call may be recorded."
),guava.Say( "Your claim has been received and assigned claim number " f"{self.claim_number}. Please note this number for your records." ),
guava.Say( "I'm required to inform you that your policy deductible of " f"${self.policy.deductible:,.0f} will apply to this claim." ), ```
Note that Say can include dynamic content — f-strings work fine. What's deterministic is the structure and the key information, not necessarily every word. A Say item that includes a claim number will always include that claim number. The LLM may wrap it in slightly different phrasing each time, but the payload is guaranteed.
Plain Strings: Directed Naturalism
The third primitive is the most subtle. A plain string in the checklist is a goal, not a script. It says: "by the end of this step, this thing should have happened." The LLM decides how to make it happen.
This sounds dangerous. In practice, it's exactly right for the tasks that don't fit neatly into Field or Say. Consider:
"Sympathize briefly with the caller's situation and set expectations for the claims process."
"Ask if the caller has any questions about what happens next."
"Confirm the appointment details and thank the patient for their time."These tasks resist rigid scripting. "Sympathize briefly with the caller's situation" should sound different depending on whether the caller just had a fender-bender or a total loss. Scripting it would make it feel canned. Giving it to the LLM as a goal with context lets it be genuinely responsive.
The key constraint: plain strings should be used for conversational maneuvers, not for information collection. If there's a specific value you need at the end of the step, use a Field. If there's a specific thing that must be said, use a Say. If you just need the LLM to do something social — acknowledge, explain, transition — a plain string is perfect.
All Three Together: Insurance FNOL
Here's a complete First Notice of Loss flow that uses all three primitives together:
import guava
from my_claims import create_claim, get_policyclass FNOLBot(guava.CallController): def __init__(self, policy_number: str): super().__init__() self.policy = get_policy(policy_number)
self.set_persona( organization_name="Harbor Insurance", agent_name="Sam", ) self.set_task( objective="Take a first notice of loss for an auto insurance claim", checklist=[ guava.Say( "I'm sorry to hear you've been in an incident. I'm here to help " "you get your claim started. This should only take a few minutes." ), guava.Field( key="incident_date", field_type="date", description="Date the incident occurred", ), guava.Field( key="incident_description", field_type="text", description="Brief description of what happened", ), guava.Field( key="other_party_involved", field_type="boolean", description="Whether another party was involved in the incident", ), guava.Field( key="injuries_reported", field_type="boolean", description="Whether any injuries were reported", ), "Express empathy and let the caller know a claims adjuster will follow up within 24 hours.", guava.Say( f"Your claim has been opened. Your deductible for this claim type " f"is ${self.policy.deductible:,.0f}. A claims adjuster will contact " f"you at {self.policy.contact_phone} within one business day." ), "Ask if the caller has any immediate questions before ending the call.", ], on_complete=self.open_claim, )
def open_claim(self, result): create_claim( policy_id=self.policy.id, incident_date=result["incident_date"], description=result["incident_description"], other_party=result["other_party_involved"], injuries=result["injuries_reported"], ) self.end_call() ```
Look at what each primitive is doing. The opening Say sets the emotional tone with scripted empathy — important because the exact framing of this moment matters. The Field items collect structured data that will populate the claim record. The plain string in the middle does the social work of transitioning out of data collection and into closure. The closing Say delivers the compliance-required information about deductibles and follow-up. The final plain string gives the LLM room to handle whatever question the caller has, naturally.
Three primitives. One real-world call flow. No 400-word system prompt required.