White paper

Building Production AI Agents Without a Framework: Eight Lessons from a Clinical Documentation Platform

Eight lessons from shipping a tool-using agent for mental-health clinicians

By 7 min read

The short version. A production AI agent is mostly not the model. It's the loop around the model: which tools the model can call, how long it may run, what it can see, and what happens when something fails. We built that loop for PsyFi Practice, the clinical documentation platform for therapists and psychologists from PsyFi Technologies, an OakenCloud company. It's about 400 lines of Python, with no agent framework. The same loop now runs a second product, a public-facing front-desk chatbot for practices. This paper covers the eight lessons that mattered most, each backed by a real incident or measurement.


What does the agent actually do?

A clinician opens a chat and asks for help, for example: "Draft a progress note from today's session with this client." The agent can:

  • Fetch the session transcript recorded by the platform's scribe feature
  • Look up, create, or update client records and link the conversation to a client
  • Parse psychological assessment reports (BASC-3, WAIS-IV, WISC-V, Woodcock-Johnson IV) into structured scores
  • Retrieve clinical references from a curated library and journal feeds, citing them in a Sources footer
  • Write and revise the note live in a side panel the clinician edits alongside the chat

That's 13 production tools for clinicians and 8 more for the front-desk chatbot (FAQ answers, intake, provider matching, appointment booking, and a crisis escalation to a human). Responses stream token by token, so the clinician watches the note being written.

The platform runs on AWS (EC2 behind a load balancer with a web application firewall, RDS Postgres, Celery workers) and uses Azure OpenAI models. The model is chosen per conversation by plan and query complexity.


Lesson 1: You probably don't need an agent framework

A tool-calling agent is a loop: call the model, run the tools it asked for, send the results back, and repeat until it answers. Frameworks add abstractions on top of that loop. In a regulated product, those abstractions are where behavior you didn't expect hides.

Our previous chatbot product was built on LangChain and LangGraph. When we rebuilt it as the front-desk chatbot, we used the same small, in-house loop as the clinician agent. The loop has hard limits that are easy to review:

Limit Value Why
Rounds per turn 5 Stops the model from looping forever on a tool that keeps failing
Total tool calls per turn 10 Caps cost and blast radius
Total tool time per turn 480 s Bounds how long a user waits
Per-tool timeout Set per tool A slow transcript fetch shouldn't hold up a quick lookup
Parallel tools Up to 4 Speed, without overwhelming downstream services

Timeouts start when a tool is submitted to the pool, not when it starts running. Otherwise a queued call can wait indefinitely. Before the fix, a timed-out worker could also block the pool from shutting down, and we caught that in review.

Lesson 2: Rebuild the tool list every round, and enforce it in the executor

A model only "sees" the tools you describe to it, but describing a tool is not the same as authorizing it. Our loop rebuilds the available tool list every round from three inputs: feature flags, the user's plan, and a per-practice allow-list. The executor then refuses any call to a tool that isn't on that list, even if the model names it anyway.

This one rule handles plan gating (assessment-report tools are for higher tiers), emergency shutoffs (flip a flag and the tool disappears mid-conversation), and multi-tenant safety. You get these without writing prompt instructions that the model might ignore.

Lesson 3: Streaming is a protocol. Treat it like one.

Server-sent events (SSE) look simple: write data: <text> and a blank line. But a multi-line response breaks that naive format, because a blank line inside the text ends the event early. When we first turned on tool use, clinical notes (which are full of blank lines) were being cut off. In one QA run, 12 of 19 responses were incomplete. We switched the feature off with a flag in minutes and fixed the encoding.

Two practices came out of that incident:

  • Every new agent path ships behind a kill switch. Live token streaming, tool use, and the side-panel draft each have their own flag.
  • Metadata travels in typed frames, not mixed into the text. Tool start and finish, citations, and draft updates each have their own frame type. Exactly one frame type, the draft update, is allowed to carry clinical content, and that exception is written down.

After the fix, a typical response arrived as about 340 incremental frames, with the first text visible in about 1.7 seconds. Before the fix it arrived as a single block at the end.

Lesson 4: Tools run in threads. Give them snapshots, not live objects.

Running tools in parallel means running them on threads that don't share the web request's database session or user context. Two independent code reviewers found the same bug: a tool reading a user object whose database session had already expired. We introduced a frozen, read-only snapshot of the user (ID, plan, permissions) that's created before tools run and passed to every tool explicitly. Tools never touch the request object.

This applies to any agent that runs tools concurrently. Decide exactly what each tool can see, and make that a plain data object you can test.

Lesson 5: De-identification has to understand context, or it breaks the agent

Before any text reaches a third-party model, it's de-identified (names, dates, contact details, and other identifiers are detected and replaced). This is on by default and fails closed: if the de-identification service is down, the request doesn't go through. Turning it off for a practice requires a written justification, which is recorded.

The surprise was that blanket redaction broke the agent's own tools. If a clinician asked "open my notes for [client name]," the name was redacted before the model saw it, so the model couldn't call the lookup tool with the right argument. The fix keeps names from the clinician's own client roster intact in that step, while still removing every other identifier. Privacy controls and tool use have to be designed together.

Lesson 6: Measure what happens before the model call

When users said "it's slow," the obvious suspect was the model. The data said otherwise. Over a week of production traffic (354 requests), pre-processing before the model call had a median of about 1.1 s and a 95th percentile of about 4.2 s, with a worst case over 20 s. Most of that came from two things:

  • Cold starts of the de-identification engine. A new worker spent 3+ seconds loading language models on its first request.
  • Duplicate embedding calls for the same text within one request.

Warming workers at startup, caching embeddings per request, and recycling workers less often brought warm pre-processing to about 0.8–0.9 s. We now track time to first token against 1.5 s and 3 s targets on a dashboard.

Lesson 7: Check privacy in every output path, not one central filter

Sensitive data can leak through more than the chat window. We found and closed separate paths for each of these:

  • Application logs. Every log line passes through redaction, including uploaded file names, which often contain a client's name.
  • Error reports. Our error tracker never receives request bodies, and local variables in stack traces from tool code are scrubbed.
  • Exceptions. A budget-exceeded error was rewritten so its text never includes the tool arguments.
  • Product analytics. Only explicitly allowed properties are sent, and numbers are bucketed.
  • Streaming frames, as described in lesson 3.

No single filter catches all of these. List every place data leaves the process and check each one.

Lesson 8: Pre-deploy checks must exercise exactly what production runs

Our worst incidents weren't model failures. Production depended on something that staging and tests didn't:

  • A media-processing binary that existed on staging but not on a rebuilt production server
  • A job queue that used one message broker in production and a different one on staging, so a broker failure was invisible before release
  • A payment library upgrade that broke in production because the tests mocked its responses as plain dictionaries
  • A login loop caused by cookie size, which a scripted smoke test couldn't see but a real browser hit immediately

Each now has a pre-deploy check or a regression test. Staging gets a browser-driven QA run before every production release, and production deploys need explicit approval.


How we build: AI-assisted, with independent review

We use AI coding agents heavily to build these systems, and that makes review more important. Every significant change gets reviewed by two independent AI reviewers, an automated daily review of open pull requests, and a human-driven QA run on staging. The platform has about 3,600 automated tests. Over the last three months the project merged 140 pull requests.

A checklist for your first production agent

  1. Write the loop yourself, with limits on rounds, total calls, total time, and per-tool time.
  2. Rebuild the tool list every round from flags, plan, and tenant, and enforce that list in the executor.
  3. Put every new capability behind a kill switch.
  4. Use a real streaming protocol with typed metadata frames.
  5. Give tools a frozen snapshot of who the user is, never the live request.
  6. De-identify by default, fail closed, and test that your tools still work afterward.
  7. Measure time to first token, and the time spent before the model call.
  8. List every place data leaves the process (logs, errors, analytics, streams) and check each one.
  9. Make staging match production exactly in the components that can break.

More from OakenCloud