> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Healthcare patient portal

> Book appointments and read records from a FHIR API for a verified patient, behind a consent preprocessor and a disclaimer postprocessor

This agent lets a verified patient book an appointment and read their medications, allergies, and conditions from a FHIR-compatible EMR API. A [preprocessor](/concepts/processors) holds every message until the patient has consented, a postprocessor appends a disclaimer to every reply, and the patient id comes from the user profile, never from the conversation. It is six files, and every TypeScript file compiles against `lua-cli` 3.33.0; run it with the steps on [Running any example](/examples/overview#running-any-example). The example shows the integration shape; it is not a compliance review.

*Verified against lua-cli 3.33.0.*

## The conversation

1. The patient's first message is blocked by `hipaa-consent` with a request to reply "I consent". When they do, the preprocessor records the consent on their profile and lets the message through; later messages pass without a check.
2. The patient asks for an appointment. `schedule_appointment` reads `patientId` from the profile and posts a FHIR `Appointment` for that patient and the chosen practitioner.
3. The patient asks about their medications. `view_medical_records` reads the matching FHIR resources for the same patient id.
4. `medical-disclaimer` appends one fixed sentence to every reply.

## Primitives and channels

* [Skill and tools](/concepts/skills-and-tools): `patient-portal`, with `schedule_appointment` and `view_medical_records`.
* [Preprocessor](/reference/sdk/preprocessor) `hipaa-consent` at priority 10, and [postprocessor](/reference/sdk/postprocessor) `medical-disclaimer`.
* Runtime objects: `User.get`, `user.update`, and `env` for `EMR_API_URL` and `EMR_API_KEY`.
* Channels: the [web widget](/channels/web-widget/quickstart) inside the signed-in portal, where the portal can write `patientId` to the profile. Any other channel works once that field is set.

## The code

The tools refuse when the profile has no `patientId`; the skill's `context` tells the model what to do then.

```ts src/skills/tools/ScheduleAppointmentTool.ts expandable theme={null}
import { LuaTool, User, env } from 'lua-cli';
import { z } from 'zod';

export default class ScheduleAppointmentTool implements LuaTool {
  name = 'schedule_appointment';
  description = 'Request an appointment for the verified patient with a practitioner at a given time';

  inputSchema = z.object({
    practitionerId: z.string().describe('FHIR Practitioner id, for example "prac-204"'),
    start: z.string().datetime().describe('Requested start time in ISO 8601'),
    reason: z.string().describe('Why the patient wants to be seen'),
  });

  async execute(input: z.infer<typeof this.inputSchema>) {
    // The patient id was written to the profile when the patient verified their identity.
    const user = await User.get();
    const patientId = user?.data.patientId;
    if (!patientId) {
      return { scheduled: false, reason: 'The patient has not verified their identity in this portal' };
    }

    const res = await fetch(`${env('EMR_API_URL')}/Appointment`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${env('EMR_API_KEY')}`,
        'Content-Type': 'application/fhir+json',
      },
      body: JSON.stringify({
        resourceType: 'Appointment',
        status: 'proposed',
        reasonCode: [{ text: input.reason }],
        requestedPeriod: [{ start: input.start }],
        participant: [
          { actor: { reference: `Patient/${patientId}` }, status: 'accepted' },
          { actor: { reference: `Practitioner/${input.practitionerId}` }, status: 'needs-action' },
        ],
      }),
    });
    if (!res.ok) {
      throw new Error(`EMR responded ${res.status}`);
    }

    const appointment = await res.json();
    return { scheduled: true, appointmentId: appointment.id, start: input.start };
  }
}
```

The records tool searches one FHIR resource type by patient and returns the resources for the model to summarize.

```ts src/skills/tools/ViewMedicalRecordsTool.ts theme={null}
import { LuaTool, User, env } from 'lua-cli';
import { z } from 'zod';

export default class ViewMedicalRecordsTool implements LuaTool {
  name = 'view_medical_records';
  description = "Read the verified patient's medications, allergies, or conditions from the EMR";

  inputSchema = z.object({
    recordType: z.enum(['MedicationStatement', 'AllergyIntolerance', 'Condition']),
  });

  async execute(input: z.infer<typeof this.inputSchema>) {
    const user = await User.get();
    const patientId = user?.data.patientId;
    if (!patientId) {
      return { records: [], reason: 'The patient has not verified their identity in this portal' };
    }

    const url = new URL(`${env('EMR_API_URL')}/${input.recordType}`);
    url.searchParams.set('patient', patientId);
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${env('EMR_API_KEY')}`, Accept: 'application/fhir+json' },
    });
    if (!res.ok) {
      throw new Error(`EMR responded ${res.status}`);
    }

    const bundle = await res.json();
    const entries: Array<{ resource: Record<string, unknown> }> = bundle.entry ?? [];
    return { recordType: input.recordType, records: entries.map((e) => e.resource) };
  }
}
```

The skill never asks for a patient id.

```ts src/skills/patient-portal.skill.ts theme={null}
import { LuaSkill } from 'lua-cli';
import ScheduleAppointmentTool from './tools/ScheduleAppointmentTool';
import ViewMedicalRecordsTool from './tools/ViewMedicalRecordsTool';

export default new LuaSkill({
  name: 'patient-portal',
  description: 'Appointments and medical records for a verified patient',
  context: `Both tools act on the patient who is signed in; never ask for a patient id.
If a tool reports that identity is not verified, send the patient to the portal's
verification page instead of retrying. Explain records in plain language and remind the
patient to raise clinical questions with their provider.`,
  tools: [new ScheduleAppointmentTool(), new ViewMedicalRecordsTool()],
});
```

The preprocessor runs before the model sees a message; `block` ends the turn with its `response`, and `proceed` lets the message through.

```ts src/preprocessors/hipaaConsent.ts theme={null}
import { PreProcessor } from 'lua-cli';

export default new PreProcessor({
  name: 'hipaa-consent',
  description: 'Holds every message until the patient has consented to share health information',
  priority: 10,

  execute: async (user, messages) => {
    if (user.data.hipaaConsentGiven === true) {
      return { action: 'proceed' };
    }

    const text = messages.flatMap((m) => (m.type === 'text' ? [m.text] : [])).join(' ');
    if (/\bI consent\b/i.test(text)) {
      await user.update({ hipaaConsentGiven: true, hipaaConsentAt: new Date().toISOString() });
      return { action: 'proceed' };
    }

    return {
      action: 'block',
      response:
        'Before I can help with appointments or records, please confirm that you consent to sharing your health information in this chat. Reply "I consent" to continue.',
    };
  },
});
```

The postprocessor receives the reply and returns the text to send.

```ts src/postprocessors/medicalDisclaimer.ts theme={null}
import { PostProcessor } from 'lua-cli';

export default new PostProcessor({
  name: 'medical-disclaimer',
  description: 'Appends the portal disclaimer to every reply',

  execute: async (_user, _message, response) => ({
    modifiedResponse: `${response}

This portal does not give medical advice. In an emergency, call your local emergency number.`,
  }),
});
```

The agent registers the skill and both processors.

```ts src/index.ts theme={null}
import { LuaAgent } from 'lua-cli';
import patientPortalSkill from './skills/patient-portal.skill';
import hipaaConsent from './preprocessors/hipaaConsent';
import medicalDisclaimer from './postprocessors/medicalDisclaimer';

const agent = new LuaAgent({
  name: 'riverside-patient-portal',
  persona: `You are the patient portal assistant for Riverside Clinic.
Help patients book appointments and read their records. Be calm and precise, use plain
language for medical terms, and never interpret results or suggest treatment.
Direct emergencies to the emergency number and billing questions to the billing office.`,
  skills: [patientPortalSkill],
  preProcessors: [hipaaConsent],
  postProcessors: [medicalDisclaimer],
});
```

## First run

Run the preprocessor with a first message on the web widget's channel, `pop`; it blocks and returns the consent request.

```bash theme={null}
lua env sandbox -k EMR_API_URL -v https://emr.example.com/fhir
lua env sandbox -k EMR_API_KEY -v <api-key>
lua test --ci preprocessor --name hipaa-consent --input '{"message":"Can I see my medications?","channel":"pop"}'
```

```text Output theme={null}
✅ Compiled 6 primitives (1 agent, 1 skill, 2 tools, 1 preprocessor, 1 postprocessor) in 538ms
✅ Selected preprocessor: hipaa-consent
🚀 Executing preprocessor...
Input message: Can I see my medications?
Channel: pop
✅ PreProcessor execution successful!

Action: BLOCK
Response: Before I can help with appointments or records, please confirm that you consent to sharing your health information in this chat. Reply "I consent" to continue.
```

The same command with `"message":"I consent"` proceeds and writes `hipaaConsentGiven` to the test user's profile. Test a tool with `lua test --ci skill --name view_medical_records --input '{"recordType":"Condition"}'`; without `patientId` on the profile it returns the not-verified reason. For the full loop with the model, send `lua chat --ci -e sandbox -m "Can I see my medications?" -t`; it uploads the `.env` values with the sandbox version. Then release it with `lua push all --ci --force`, `lua version create --ci -m "<message>"`, and `lua version promote <n>`; [Release an agent to production](/ship/releasing) explains what each command changes.

## Ways to make it yours

* Write `patientId` to the profile after your portal verifies identity, either from a verification tool with `user.update({ patientId })` or through the [user data REST endpoints](/reference/rest/user-data). The tools trust that field and nothing the model says.
* Replace the consent text and the disclaimer with the wording your compliance team approves; both are strings in one file each.
* Vary the disclaimer by channel with the fourth argument of the postprocessor's `execute` (`pop` for the web widget, `whatsapp` for WhatsApp), for example a shorter sentence on WhatsApp.
* Summarize FHIR resources into plain fields inside `view_medical_records` when the model's replies are too raw; the tool's return value is what the model reads.

## Next steps

<Columns cols={2}>
  <Card title="Add a processor" href="/build/add-a-processor">
    Order, priority, blocking, and testing preprocessors and postprocessors.
  </Card>

  <Card title="Identify users" href="/build/identify-users">
    The profile, custom fields, and cross-channel identity.
  </Card>

  <Card title="Call your API" href="/build/call-your-api">
    Keys in `env()`, errors, and timeouts when a tool calls an external service.
  </Card>
</Columns>
