In 2024 I worked on Tuula, a report generator for clinicians and specialists. The flow sounds simple: upload a recording of a session, get a transcript from AWS Transcribe, use models on AWS Bedrock to answer questions about the session, and turn all of that into a draft report.
The demo version of that is easy to build. The real product is harder, because at the end of the flow a professional has to put their name on the report. That one constraint shaped almost every engineering decision, and most of those decisions had little to do with prompts.
These are the lessons I took away, written for anyone about to put a model behind a button.
Treat model output as a draft, not an answer
The most important decision was about language and state, not code. Nothing the model produced was a finished report. It was a draft, it lived in a draft state, and the only way to finalize it was for a person to review it.
That changes what you build. If AI output is a draft, the editing experience is the main product surface, not a fallback for when the model gets it wrong. In Tuula that meant:
- Reviewers could open historic reports while working on the current draft.
- Drafts were fully editable, through forms that kept the report's structure intact.
- Reviewers could regenerate individual insights with extra guidance instead of rerolling the whole report.
That last point matters more than it sounds. If regeneration is all-or-nothing, every correction risks breaking a section that was already right. Targeted regeneration lets a person fix one paragraph and keep the rest of their review.
A useful rule: every piece of AI output should have an obvious owner and an obvious next action.
Keep the evidence next to the claim
A reviewer can only trust a generated sentence if they can check it quickly, so the source material has to stay close to the output.
In Tuula, the job detail view kept the audio player, the transcript and the report one tab apart, with audio playing inline. Checking a finding never meant leaving the job or downloading a file.
If I built it again, I'd go one step further: put the transcript side by side with the report and link generated statements back to transcript timestamps. People check more when checking is cheap, and they trust the tool more once they've seen it be right.
Model AI work as jobs, not requests
Transcribing and analyzing a long recording takes minutes, not milliseconds. A request-response flow with a spinner won't survive that. Users close the tab, connections drop, and you lose track of what is still running.
The more robust model is a job with explicit states that the UI can always show:
const jobStatuses = [
"uploaded",
"transcribing",
"analyzing",
"ready_for_review",
"finalized",
"failed",
] as const;
type JobStatus = (typeof jobStatuses)[number];
The exact states will vary by product, but the principle holds: users should always be able to see where their work is, including when it failed.
That's why the jobs dashboard was one of the most important screens in the app, even though it had no AI in it. Filters, search and pagination lived in the URL using nuqs, so a filtered view survived a refresh and could be shared with a colleague:
import {
parseAsArrayOf,
parseAsInteger,
parseAsString,
parseAsStringLiteral,
useQueryStates,
} from "nuqs";
export const jobFilters = {
status: parseAsArrayOf(parseAsStringLiteral(jobStatuses)).withDefault([]),
q: parseAsString.withDefault(""),
page: parseAsInteger.withDefault(1),
};
export function useJobFilters() {
return useQueryStates(jobFilters);
}
Get large files out of your API's way
Audio files are big. Streaming them through your own API server costs memory, adds timeouts, and turns your API into the bottleneck for the slowest part of the flow.
Presigned uploads solve that. The API signs a short-lived URL, the browser uploads straight to S3, and the API only records that the upload happened. For progress you still need XMLHttpRequest, because fetch doesn't report upload progress in most browsers:
export function uploadWithProgress(
url: string,
file: File,
onProgress: (percent: number) => void,
) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () =>
xhr.status < 300
? resolve()
: reject(new Error(`Upload failed with status ${xhr.status}`));
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(file);
});
}
Pair it with optimistic progress tracking, so people see their upload moving immediately instead of staring at a frozen button.
Validate structured output like any untrusted input
When you ask a model for structured data, you'll usually get it. "Usually" isn't good enough for something a clinician will sign. JSON modes and tool calling make malformed output rarer, but they don't remove the need to check it.
The approach I like is a single Zod schema that does two jobs: it validates what the model returns, and it powers the form the reviewer edits.
import { z } from "zod";
export const findingsSchema = z.object({
summary: z.string().min(1),
observations: z.array(
z.object({
label: z.string().min(1),
detail: z.string().min(1),
}),
),
recommendations: z.array(z.string().min(1)),
});
export type Findings = z.infer<typeof findingsSchema>;
export function parseFindings(raw: string): Findings | null {
try {
const result = findingsSchema.safeParse(JSON.parse(raw));
return result.success ? result.data : null;
} catch {
return null;
}
}
With React Hook Form and zodResolver(findingsSchema), the same rules apply to the model's output and to the person's edits. When parsing fails, fail that section only: mark it as needing regeneration and leave everything else usable. One bad response should never take down the whole report.
Build a mock layer on day one
AI pipelines move slowly early on. Models change, prompts get rewritten, backend services aren't deployed yet, and cloud access takes time to set up. If the frontend waits for all of that, it waits for weeks.
On Tuula I built a full mock API layer covering auth, jobs, reports and uploads. It sat behind the same interface as the real client, so switching between them took one environment variable:
export interface JobsApi {
list(filters: JobFilters): Promise<Paginated<Job>>;
get(id: string): Promise<Job>;
regenerateSection(
id: string,
section: string,
guidance?: string,
): Promise<Job>;
}
export const jobsApi: JobsApi =
process.env.NEXT_PUBLIC_API_MODE === "mock" ? mockJobsApi : httpJobsApi;
The mock paid for itself several times over:
- Unblocked UI work. Dashboards, editors and transcript views were built before the backend was available.
- Deterministic edge cases. Forcing a job into
failed, or simulating a very slow transcription, became trivial. Those are the states real users hit and nobody tests. - Demos and onboarding. New developers and stakeholders could run the whole product locally without access to any cloud account.
The key is keeping the mock at the same boundary as the real client and returning the same types. A mock that drifts from the real API is worse than no mock at all.
A checklist for your next AI feature
If I were starting an AI feature tomorrow, this is the list I'd begin with:
- Decide who owns the output, and design the review step before the generation step.
- Keep the source material within one click of the generated content.
- Model generation as an async job with visible, recoverable states.
- Validate every structured response, and fail per section rather than per page.
- Make regeneration targeted and guidable, not all-or-nothing.
- Record which model and prompt version produced each draft, so you can explain and compare results later.
- Build the mock layer before the pipeline is ready.
The model is the part that gets demoed. The review loop is the part people use every day, and that's where trust is earned.
Share this article
Found this useful? Spread the word across your favourite networks or drop it in your team chat.
About the author
I’m Haris—partnering with product teams to ship resilient, measurable web experiences. Let’s connect if you’d like a deeper discussion about anything you read here or want to collaborate.


