Law firms lose more potential clients than most partners want to admit. Not because their attorneys aren't good β but because by the time someone calls back, the client has already talked to three other firms.
The window to book an initial consultation is under 5 minutes according to research on lead response time. The average law firm responds in 48 hours.
This tutorial shows how to close that gap entirely. We'll build a Legal Intake Assistant β an AI agent that takes a client's legal question in natural language, identifies the right attorney, and books an initial consultation in under 60 seconds.
No receptionist. No phone tag. No lost leads.
Architecture
Here's the full flow:
Client describes problem
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββ
β LangGraph Agent β
β β
β classify_case βββΊ find_attorney βββΊ check_slots β
β β β
β book_consult β
β β β
β confirm ββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
Orita API (schedules confirmed slot)
β
βΌ
Attorney's calendar + Client email confirmation
The agent is a StateGraph β each step is a node, with conditional edges that route based on tool call results. This is where LangGraph shines: complex multi-step flows that need to remember what happened two steps ago.
Why LangGraph for Legal Intake?
Legal intake isn't a single API call. It's a workflow:
- Understand the legal problem (what kind of case is this?)
- Route to the right attorney (employment? criminal? family?)
- Check availability (not just any slot β the right attorney)
- Book and confirm with case-specific notes
LangGraph's StateGraph is perfect for this:
- Stateful β the agent carries
case_type,attorney,available_slots, andbooking_idacross steps - Conditional routing β if no attorney matches, it falls back gracefully
- Tool-native β each step can call external APIs (Orita) without spaghetti code
- Auditable β every state transition is logged; you can see exactly what happened
A single LangChain agent could do this, but you'd lose visibility into which step failed β critical in a legal context.
The Code
State definition
The graph carries structured state across all nodes:
class IntakeState(TypedDict):
messages: Annotated[list, add_messages]
case_type: str | None # classified legal area
attorney: dict | None # matched attorney record
available_slots: list[str] # ISO datetime strings
booking_id: str | None # confirmed booking reference
client_name: str
client_email: str
error: str | None
Unlike a plain dict, TypedDict gives you type hints and makes every field explicit. When something goes wrong in production, you know exactly which field is None.
Tool: classify_legal_case
The first node classifies the client's problem into a legal practice area. The LLM does the heavy lifting here β it reads the client's description and returns one of: employment, criminal, family, tax, real_estate, immigration, general.
@tool
def classify_legal_case(description: str) -> str:
"""
Classify a legal problem into a practice area.
Returns JSON with case_type, reasoning, urgency, and notes.
"""
# The LLM reads the description and classifies it.
# Result: { "case_type": "employment", "urgency": "high", ... }
The @tool decorator from LangChain turns this into a structured function that the LLM can call via tool use. The LLM decides when to call it β you don't have to hardcode the sequence.
Tool: find_attorney
Once we know the case type, we match it to an attorney:
@tool
def find_attorney(case_type: str) -> str:
"""Find the best attorney for a given legal case type."""
attorney = ATTORNEY_DIRECTORY.get(case_type, ATTORNEY_DIRECTORY["general"])
return json.dumps({
"found": True,
"attorney": {
"name": attorney["name"],
"specialty": attorney["specialty"],
"bio": attorney["bio"],
"provider_id": attorney["provider_id"], # β Orita
"event_type_id": attorney["event_type_id"], # β Orita
}
})
In production, ATTORNEY_DIRECTORY would be a database query or a call to your CRM. The key fields are provider_id and event_type_id β these are the Orita identifiers that unlock availability and booking.
Tool: get_available_slots (Orita)
This is where Orita does its job:
@tool
def get_available_slots(provider_id: str, event_type_id: str, days_ahead: int = 5) -> str:
"""Fetch available consultation slots via Orita."""
for offset in range(days_ahead):
check_date = date.today() + timedelta(days=offset + 1)
slots = orita.slots(
event_type_id=event_type_id,
date=check_date.isoformat(),
provider_id=provider_id,
)
# Collect and return slots...
One orita.slots() call returns all available times for a given attorney on a given day. No scraping calendars. No email chains. Just structured availability data your agent can reason about.
Tool: book_consultation (Orita)
The booking step creates a confirmed appointment and triggers the confirmation email:
@tool
def book_consultation(
provider_id: str,
event_type_id: str,
slot: str,
client_name: str,
client_email: str,
case_summary: str,
) -> str:
"""Book an initial legal consultation via Orita."""
booking = orita.book(
provider_id=provider_id,
event_type_id=event_type_id,
date=booking_date,
time=booking_time,
name=client_name,
email=client_email,
notes=f"Legal intake β {case_summary}",
)
return json.dumps({
"success": True,
"booking_id": booking["id"],
"next_steps": [
"Calendar invite sent within 5 minutes.",
"Prepare relevant documents.",
"Attorney-client privilege applies from this call.",
]
})
The notes field is important: it passes the case summary directly into the attorney's calendar entry. When the attorney opens the appointment, they already know what to expect.
Graph assembly
graph = StateGraph(IntakeState)
graph.add_node("agent", agent_node) # LLM reasoning
graph.add_node("tools", tool_node) # Tool execution
graph.set_entry_point("agent")
graph.add_conditional_edges(
"agent",
should_continue, # β "tools" if tool calls pending, else END
{"tools": "tools", "end": END},
)
graph.add_edge("tools", "agent") # Always return to agent after tools
return graph.compile()
The should_continue function is the routing logic:
def should_continue(state: IntakeState) -> Literal["tools", "end"]:
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
This creates a ReAct loop: the agent reasons, calls tools, reads results, reasons again. It terminates when the agent produces a final message with no pending tool calls β which happens after the booking confirmation.
What the Attorney Sees
When the booking is confirmed, the attorney's calendar entry includes:
- Client name and email β ready for a follow-up email
- Case type and urgency β from the classification step
- Case summary β extracted from the client's own words, in the booking notes
- Booking reference β for your case management system
The attorney walks into the consultation already knowing: "this is a wrongful termination case with potential OSHA retaliation, high urgency, for Alex Rivera."
That's the difference between a $0 missed lead and a $15,000 case.
Real Numbers
| Metric | Traditional Intake | AI Intake Assistant | |--------|-------------------|---------------------| | Response time | 48 hours average | < 60 seconds | | Leads captured after hours | 0% | 100% | | Staff time per intake | 15-20 minutes | 0 minutes | | Booking accuracy | ~85% (manual errors) | >99% | | Case routing accuracy | Depends on receptionist | Consistent + auditable |
The 60-second figure isn't marketing β it's the time from the client hitting "send" to receiving a calendar invite. The graph executes 4 tool calls sequentially in about 3-8 seconds; the rest is LLM reasoning and network latency.
Extend This
1. CRM Integration
After booking, push client data to your CRM:
@tool
def create_crm_contact(name: str, email: str, case_type: str, booking_id: str) -> str:
"""Create a contact in your CRM after intake."""
# Salesforce, HubSpot, Clio Manage, or your own system
crm.contacts.create({
"name": name,
"email": email,
"matter_type": case_type,
"intake_source": "AI Assistant",
"orita_booking_id": booking_id,
})
2. Conflict Check
Before booking, check for conflicts of interest:
@tool
def check_conflicts(client_name: str, opposing_party: str) -> str:
"""Check if the firm has represented the opposing party."""
# Query your conflict database
conflicts = db.query("SELECT * FROM clients WHERE name = ?", [opposing_party])
return json.dumps({"has_conflict": len(conflicts) > 0})
Add this as a node before book_consultation β the graph will automatically route through it.
3. Intake Form
Gather more information before the consultation:
@tool
def send_intake_form(client_email: str, case_type: str) -> str:
"""Send a pre-consultation intake form to the client."""
form_url = f"https://yourfirm.com/intake/{case_type}"
send_email(client_email, f"Please complete this before your consultation: {form_url}")
return json.dumps({"sent": True, "form_url": form_url})
4. Webhooks
Orita sends webhooks when bookings are created, modified, or cancelled. Register a webhook endpoint to:
- Notify the case management team
- Trigger document preparation workflows
- Update your intake dashboard in real-time
Clone & Run in 5 Steps
Prerequisites: Python 3.11+, OpenAI API key, Orita account
# 1. Clone the examples repo
git clone https://github.com/Alkilo-do/orita-python
cd orita-python
# 2. Install dependencies
pip install langgraph langchain-openai langchain-core orita-sdk
# 3. Set your API keys
export OPENAI_API_KEY=sk-your-openai-key
export ORITA_API_KEY=orita_your-key
# 4. Run the demo (uses the wrongful termination scenario)
python examples/legal_intake_assistant.py
# 5. Run in interactive mode
python examples/legal_intake_assistant.py --interactive
The demo runs the full intake flow β classify, find, check, book, confirm β and prints each step with timing. You'll see a booking confirmation in your Orita dashboard within seconds.
Next Steps
This example uses a static attorney directory. In production, you'd:
- Connect to Orita's professionals API to dynamically fetch available attorneys
- Add authentication β only allow clients to book during active hours, or gate with a form
- Set up webhooks to notify your team when a new intake is booked
- Deploy as a serverless function behind your website's "Contact Us" form
The graph structure makes all of this modular. Add a node, wire an edge, done.
β Get your Orita API key and start building
The full source code is at: github.com/Alkilo-do/orita-python/examples/legal_intake_assistant.py
Questions? Reach us at developers@orita.online or open an issue on GitHub.
