Skip to content

Use writable extensions

Goal: ship an app that writes a "preferred pharmacy" string onto each Patient, in a way that works across every tenant that installs the app.

Prerequisites:

  • A scaffolded app (orion init).
  • A configured sandbox tenant.
  • user/Patient.read and user/Patient.write scopes.

What writable extensions are

A writable extension is a manifest declaration that says "this app needs to write a FHIR Extension with this shape, on these resource types." You declare structural intent — a key, a value type, the resources it applies to — and the host EMR resolves a per-tenant StructureDefinition URL when the tenant admin installs your app.

Apps in the marketplace ship to many tenants. Each tenant has its own FHIR server, its own URL space, its own catalog of StructureDefinition records. If your app baked a literal URL (https://acme.example/StructureDefinition/preferred-pharmacy) into the bundle, that URL would only work for one tenant. Writable extensions move the URL out of the bundle: you declare the field by key in the manifest, the host gives you the resolved URL at install time, and your app fetches that URL at runtime.

How they work

Three stages — declare, install, write.

1. Declare. Put a writableExtensions[] entry in orion-app.json. The host's manifest validator enforces the schema (key shape, value-type allow-list, the 50-entry cap) at publish time; orion validate catches the same errors locally.

2. Install. When a tenant admin installs your app, the install dialog shows your declared writable extensions and offers three choices per entry:

  • Create new — host writes a fresh StructureDefinition in the tenant's FHIR catalog and binds the URL to your install.
  • Map existing — host shows compatible StructureDefinition records already in the tenant (matched by value[x] type and context.expression) and lets the admin pick one. Useful when the tenant already has a canonical "preferred-pharmacy" definition.
  • Skip — admin opts your install out of this field. Your code won't see the URL at runtime and can't write the extension.

The host records the result in the install row's extension_url_map JSON column, keyed by your declared key.

3. Write. At runtime your app reads the resolved map via the FHIR $get-installation-context operation, then uses the per-tenant URL to write extensions on the host-allowed resources. Every PUT/POST that includes an Extension.url not in the install's allow-list is rejected by the host with a 403 — the allow-list is the security boundary, not a CSS-style optional hint.

Steps

  1. Declare the writable extension in orion-app.json. orion init scaffolds an example entry already; this is the shape:

    json
    {
      "scope": [
        "user/Patient.read",
        "user/Patient.write",
        "user/AppInstallation.read"
      ],
      "extensions": {
        "writableExtensions": [
          {
            "key": "preferred-pharmacy",
            "title": "Preferred Pharmacy",
            "description": "Patient's preferred pharmacy for prescription routing.",
            "valueType": "string",
            "appliesTo": ["Patient"]
          }
        ]
      }
    }

    user/AppInstallation.read is required so your app can call $get-installation-context at runtime to fetch the resolved URL map.

  2. Validate locally before pushing:

    bash
    orion validate

    validate will reject a malformed valueType, a bad kebab-case key, an empty appliesTo, duplicate keys, and any manifest over the 50-entry writableExtensions[] cap.

  3. Publish, install, and observe the install dialog. orion publish pushes the bundle; in your sandbox tenant, click Install on the app. The install dialog will show your declared writable extensions; pick Create new for preferred-pharmacy and complete the install.

  4. Fetch the resolved URL map at runtime. Inside your app, call the $get-installation-context operation through bridge.fhirFetch. The response shape is a FHIR Parameters resource. The extensionUrlMap parameter has a nested part[], with one inner part per declared key, and the resolved URL on each inner part's valueUri:

    tsx
    import { useEffect, useState } from 'react';
    import { useBridge } from '@orion-ehr/app-bridge';
    
    type ParametersPart = { name: string; valueUri?: string; part?: ParametersPart[] };
    type Parameters = { parameter?: ParametersPart[] };
    
    export function usePharmacyExtensionUrl(): string | null {
        const bridge = useBridge();
        const [url, setUrl] = useState<string | null>(null);
    
        useEffect(() => {
            if (!bridge) return;
            bridge.fhirFetch<Parameters>('$get-installation-context').then((params) => {
                const entry = params.parameter?.find((p) => p.name === 'extensionUrlMap');
                const resolved = entry?.part?.find((p) => p.name === 'preferred-pharmacy')?.valueUri ?? null;
                setUrl(resolved);
            });
        }, [bridge]);
    
        return url;
    }

    A missing inner part (or one with no valueUri) means the admin chose Skip at install time. Your UI should handle that case — e.g. hide the feature, or surface a message that the admin can re-install with the field enabled.

  5. Write the extension on a Patient. Build a PUT Patient/{id} payload that includes an Extension with the resolved URL:

    tsx
    import type { OrionBridge } from '@orion-ehr/app-bridge';
    
    type PatientResource = {
        resourceType: string;
        id: string;
        extension?: Array<{ url: string; valueString?: string }>;
    };
    
    async function setPreferredPharmacy(bridge: OrionBridge, patientUuid: string, value: string, url: string) {
        const patient = await bridge.getResource<PatientResource>('Patient', patientUuid);
    
        const otherExtensions = (patient.extension ?? []).filter((e) => e.url !== url);
        const updated = {
            ...patient,
            extension: [
                ...otherExtensions,
                { url, valueString: value },
            ],
        };
    
        return bridge.updateResource<PatientResource>('Patient', patientUuid, updated);
    }

    A few details that bite if you skip them:

    • PUT replaces the whole resource. Always read first, merge your extension into the existing list, then write — otherwise you'll wipe extensions the host (or another app) put on the patient.
    • Filter out any prior entry with the same url before pushing the new one. FHIR allows multiple extensions with the same URL on a single resource, but for a single-valued field you almost always want overwrite semantics.
    • Use the URL you got from step 4, not a hardcoded string. The whole point of writable extensions is that you don't bake URLs into the bundle.
  6. Confirm the write succeeded. The host returns 200 with the updated Patient. If you typo a URL or use one outside the install's allow-list, the host returns 403 with an OperationOutcome naming the offending URL — that's the security boundary doing its job.

Verify

Open the Patient detail page in your sandbox tenant. Trigger your "Set preferred pharmacy" UI, supply a value, and confirm:

  • The Patient resource (via FHIR REST or the host's UI) now has an extension[] entry with the resolved URL and your value.
  • Writing the extension with a URL not in the install's allow-list returns 403.
  • An admin who reinstalls the app with Skip chosen for preferred-pharmacy causes your app to receive null for that key in $get-installation-context.

Common patterns

Multiple resource types. Set appliesTo: ["Patient", "Encounter"] to share one extension declaration across both. The resolved URL is the same; the host validates the URL is in the allow-list regardless of which resource type the write targets.

Multiple writable extensions. Each entry is independent. The cap is 50 entries per manifest; in practice apps that need more are usually modeling something that belongs in a custom resource, not an extension.

Conditional fields based on tenant install choices. Always treat the extensionUrlMap value as string | null. A null means Skip — don't render the field, don't try to write it. Cache the map for the duration of the session (it doesn't change while your app is mounted), but refetch on reload.

Value types beyond strings. The allow-list covers string, boolean, integer, decimal, code, date, dateTime, Coding, CodeableConcept. Pick the type that matches what you'll write — the host enforces it at write time. For coded values (e.g. a controlled vocabulary), use Coding or CodeableConcept rather than string.

Documents @orion-ehr/cli v0.0.15 — released under the MIT License.