Milano.
// tutorial · iOS · SwiftUI

Add Milano to an app you already have.

A promotional banner, an image, a title, a line of copy, and a button, described in a JSON document and rendered by your own views. The document is checked by the same gate the app runs, so what passes on your machine renders on the device, and changing the banner later means editing a document, not app code.

Node 20+ for the CLI about 30 minutes SDK 2.1

Your app is built with

  • A producer folder the CLI scaffolds, with the vocabulary and the documents, checked by npm run check.
  • A vocabulary of four components and one action, and typed bindings generated from it.
  • Four renderers mapping the document onto your SwiftUI components, and one engine that hosts the banner.
  • A banner you change by editing a document, with the app code untouched.

Two roles appear in the steps. The producer writes the vocabulary and the documents; the app registers a view per vocabulary type and renders whatever passes the gate. In a small team both are you, and the folder layout keeps them apart anyway, because the documents will outlive any one build of the app. Steps 1 to 3 and 8 are the producer's and do not depend on the platform; steps 4 to 7 follow the platform you picked.

1

Start the producer folder

Inside the app's repository, next to the project, let the CLI scaffold a folder for the documents:

Terminal
$ cd MyApp
$ npx @get-milano/cli init milano --name promo
$ cd milano && npm install && npm run check

milano init writes a working producer setup and npm run check proves it: the schema for your editor is regenerated and every document is validated with the gate the engines run. The folder contains:

FileWhat it is
vocabulary.jsonThe contract between producer and app: component types, their properties and events, the custom actions.
documents/welcome.jsonA first document using the starter vocabulary. The banner goes next to it.
documents.schema.json, .vscode/settings.jsonThe document schema specialized to your vocabulary, and the editor settings pointing at it, so typos get red squiggles as you type.
package.jsonvalidate, schema, and check scripts running the CLI.
AGENTS.md, CLAUDE.md, .claude/skills/The authoring rules for an AI agent working in the folder, and the instruction to run the check after every change.

These files are what the app will bundle in step 5: the vocabulary it registers renderers for, and the documents it renders. They stay in milano/, and the app reads them from there.

2

Declare the vocabulary

The vocabulary names what documents may use. The banner needs a column, text, an image, and a button, plus one action for the button to request. Replace vocabulary.json with:

JSONmilano/vocabulary.json
{
  "milano": "2.1.0",
  "name": "promo",
  "version": "1.1.0",
  "components": {
    "Column": { "children": true },
    "Text": {
      "properties": {
        "text": "string",
        "role": { "enum": ["title", "body"] }
      }
    },
    "Image": {
      "properties": {
        "url": "string",
        "height": "int?",
        "cornerRadius": "int?"
      }
    },
    "Button": {
      "properties": {
        "label": "string",
        "enabled": "bool"
      },
      "events": { "tap": null }
    }
  },
  "actions": {
    "openUrl": { "parameters": { "url": "string" } }
  },
  "functions": {
    "formatMoney": { "arguments": ["int", "string"], "returns": "string" }
  }
}

Each property has a type: "int?" is an optional integer, the enum is a closed set of values, and "tap": null declares an event with no payload. The action takes one parameter. Run npm run check again: the schema follows the new vocabulary, and welcome.json still validates, since everything it used is still declared.

3

Write the banner

JSONmilano/documents/banner.json
{
  "version": "2.1.0",
  "vocabulary": { "name": "promo", "min": "1.0.0" },
  "context": { "userName": "string" },
  "root": {
    "type": "Column",
    "id": "banner",
    "children": [
      {
        "type": "Image",
        "properties": {
          "url": "https://images.example.com/summer.jpg",
          "height": 160,
          "cornerRadius": 12
        }
      },
      {
        "type": "Text",
        "properties": {
          "text": { "$expr": "$concat('Summer sale, ', context.userName)" },
          "role": "title"
        }
      },
      {
        "type": "Text",
        "properties": {
          "text": "Twenty percent off every espresso machine until Sunday.",
          "role": "body"
        }
      },
      {
        "type": "Button",
        "id": "cta",
        "properties": { "label": "See the offer", "enabled": true },
        "on": {
          "tap": [ { "action": "openUrl", "url": "https://shop.example.com/sale" } ]
        }
      }
    ]
  },
  "metadata": { "campaign": "summer-2026" }
}

Three things to notice. The title is an expression: the $expr wrapper reads context.userName, a value the app injects, and the document declares that it needs it under context. The button's on.tap binds the event to the openUrl action with its parameter. And vocabulary.min says which vocabulary the document was written for, so an app holding an older one fails the build with a typed error instead of rendering the wrong thing.

Terminal
$ npm run check
documents/banner.json: valid
documents/welcome.json: valid
Try it

Change the title's role to "headline" and run the check. The gate answers with the rule it applied, the node (by path, since that node has no id), and what it expected:

Terminal
$ npm run check
documents/banner.json: SchemaViolation: schema violation (property-type) at root/children[1]: expected enum, found string

That is the same message the app would report, which is the point of running it here first. Put the role back.

Everything so far is the producer's side, and it is the same whatever the app is built with. The next four steps are the app's side, in the platform picked at the top of the page.

4

Generate the bindings

The vocabulary is machine-readable, so the app does not have to read properties by string. One command turns it into typed code, written straight into the app's source tree:

Terminal
$ npx milano bindings vocabulary.json \
    --swift-prefix Promo \
    --swift-out ../MyApp/Milano/PromoBindings.swift

The file holds one wrapper per component (PromoTextNode with text and role, PromoImageNode, PromoButtonNode with emitTap()), a Swift enum per declared enum (PromoTextRole), an exhaustive PromoAction with an unrecognized case, and PromoVocabulary.assertMatches.

A property the vocabulary declares non-optional is a non-optional property in the generated code: the gate guarantees it is there. Commit the file, and regenerate it whenever the vocabulary changes; the compiler then lists every place in the app the change touches.

5

Add the SDK and the producer files to the app

In Xcode, File, Add Package Dependencies, and enter the repository URL; choose "Up to Next Major" from 2.1.0 and add the MilanoSDK product to the app target. In a Package.swift it is:

SwiftPackage.swift
dependencies: [
    .package(url: "https://github.com/get-milano/sdk.git", from: "2.1.0")
]

A tagged release resolves to a prebuilt, signed MilanoSDK.xcframework. Then add milano/vocabulary.json and milano/documents/banner.json to the app target: drag them into the project navigator, tick the target, and leave "Copy items if needed" unchecked, so the project references the files where the CLI wrote them. Xcode copies them into the bundle at build time, and an edited document is in the next build.

6

Bridge your views

A renderer is one function: node in, view out. Each one reads the typed node and returns a view the app already has, or the platform's plain components, as here. Milano never draws anything; this file is where your design system meets the document.

SwiftMyApp/Milano/PromoBridge.swift
import MilanoSDK
import SwiftUI

/// The bridge: one renderer per vocabulary type, each mapping a typed node
/// onto a view the app already has. Milano draws nothing itself.
enum PromoBridge {
    static func registry() -> MilanoRegistry {
        var registry = MilanoRegistry()
        registry.register(ColumnRenderer(), for: "Column")
        registry.register(TextRenderer(), for: "Text")
        registry.register(ImageRenderer(), for: "Image")
        registry.register(ButtonRenderer(), for: "Button")
        return registry
    }
}

final class ColumnRenderer: MilanoRenderer {
    func render(_ node: MilanoNode) -> AnyView {
        AnyView(
            VStack(alignment: .leading, spacing: 12) {
                ForEach(node.children) { $0 }
            }
            .padding(16)
        )
    }
}

final class TextRenderer: MilanoRenderer {
    func render(_ node: MilanoNode) -> AnyView {
        let text = PromoTextNode(node)
        switch text.role {
        case .title:
            return AnyView(Text(text.text).font(.title2.bold()))
        case .body:
            return AnyView(Text(text.text).font(.body).foregroundStyle(.secondary))
        }
    }
}

final class ImageRenderer: MilanoRenderer {
    func render(_ node: MilanoNode) -> AnyView {
        let image = PromoImageNode(node)
        return AnyView(
            AsyncImage(url: URL(string: image.url)) { phase in
                if case .success(let loaded) = phase {
                    loaded.resizable().scaledToFill()
                } else {
                    Color.gray.opacity(0.2)
                }
            }
            .frame(height: image.height.map(CGFloat.init))
            .clipShape(RoundedRectangle(cornerRadius: CGFloat(image.cornerRadius ?? 0)))
        )
    }
}

final class ButtonRenderer: MilanoRenderer {
    func render(_ node: MilanoNode) -> AnyView {
        let button = PromoButtonNode(node)
        return AnyView(
            Button(button.label) { button.emitTap() }
                .buttonStyle(.borderedProminent)
                .disabled(!button.enabled)
        )
    }
}

node.children are the column's materialized children, already renderable: the container only places them. The button's emitTap() puts the event into Milano's dispatch, which runs the actions the document bound to it.

7

Create the engine and show the banner

One engine per app, created once with the vocabulary and the registry, then a builder per surface: the document, the context the document declared, and the handler for the actions it may request. Both files come from the producer folder the app bundled in step 5.

SwiftMyApp/Milano/PromoBanner.swift
import MilanoSDK
import SwiftUI

/// One engine for the whole app, created once with the vocabulary the
/// bridge implements; every screen builds its views from it.
enum Milano {
    static let engine: MilanoEngine = {
        do {
            let engine = try MilanoEngine(
                vocabularyJSON: resource("vocabulary"),
                registry: PromoBridge.registry())
            // Refuses to run against a vocabulary the bindings were not generated from.
            PromoVocabulary.assertMatches(engine)
            return engine
        } catch {
            fatalError("Milano setup failed: \(error)")
        }
    }()

    /// The banner: the document from the producer folder, bundled with
    /// the app, plus the context it declared and the handler for its actions.
    static func bannerBuilder() -> MilanoViewBuilder {
        engine.viewBuilder(document: resource("banner"))
            .context(["userName": .string("Ada")])
            .actionHandler(handle(_:))
            .label("banner")
    }

    /// The single funnel for custom actions. The gate proved `url` is a
    /// string; whether it is safe to open is the app's decision.
    @Sendable static func handle(_ action: MilanoAction) async throws -> MilanoValue? {
        switch PromoAction(action) {
        case .openUrl(let url):
            guard let url = URL(string: url), url.scheme == "https" else { return nil }
            await open(url)
        case .unrecognized(let action):
            print("unhandled action \(action.name)")
        }
        return nil
    }

    @MainActor private static func open(_ url: URL) {
        UIApplication.shared.open(url)
    }

    private static func resource(_ name: String) -> Data {
        guard let url = Bundle.main.url(forResource: name, withExtension: "json"),
              let data = try? Data(contentsOf: url)
        else { fatalError("\(name).json is not in the app bundle; add it to the target") }
        return data
    }
}

/// Drop it into any screen: the banner renders where this view sits.
struct PromoBannerView: View {
    var body: some View {
        MilanoHost(builder: Milano.bannerBuilder()) {
            ProgressView()
        } failure: { _ in
            EmptyView()   // an optional surface fails to nothing
        }
    }
}

Put PromoBannerView() wherever the banner belongs, a home screen, a list header, a sheet.

Build and run: the image loads, the title reads "Summer sale, Ada", the button opens the offer. The handler is the last capability check: the gate proved url is a string, the app decides that only https leaves it. The failure content is where a rejected document lands; for an optional surface, nothing is the right thing to show.

8

Everything else the contract gives you

The banner uses a fraction of what a document can do. Everything below is the same contract, needs no new app code beyond what a feature explicitly asks for, and is documented in full in the SDK guides.

This document is a small basket. It repeats a list with a stable identity per row, edits that list in place, keeps a derived count in step, reacts to being shown, and formats money through a function the app computes:

JSONmilano/documents/features.json
{
  "version": "2.1.0",
  "vocabulary": {
    "name": "promo",
    "min": "1.1.0"
  },
  "state": {
    "items": {
      "array": {
        "record": {
          "id": "string",
          "name": "string",
          "cents": "int"
        }
      }
    },
    "count": "int",
    "seen": "bool"
  },
  "root": {
    "type": "Column",
    "id": "basket",
    "children": [
      {
        "type": "Text",
        "id": "heading",
        "properties": {
          "text": {
            "$expr": "$concat('Basket: ', $str(state.count), ' items')"
          },
          "role": "title"
        }
      },
      {
        "type": "$repeat",
        "id": "rows",
        "items": {
          "$expr": "state.items"
        },
        "as": "item",
        "key": {
          "$expr": "item.id"
        },
        "children": [
          {
            "type": "Column",
            "children": [
              {
                "type": "Text",
                "id": "line",
                "properties": {
                  "text": {
                    "$expr": "$concat(item.name, ' ', formatMoney(item.cents, 'EUR'))"
                  },
                  "role": "body"
                }
              },
              {
                "type": "Button",
                "id": "upgrade",
                "properties": {
                  "label": "Make it a large",
                  "enabled": true
                },
                "on": {
                  "tap": [
                    {
                      "action": "$update",
                      "key": "items",
                      "at": {
                        "$expr": "item_index"
                      },
                      "field": "cents",
                      "value": {
                        "$expr": "item.cents + 50"
                      }
                    }
                  ]
                }
              },
              {
                "type": "Button",
                "id": "remove",
                "properties": {
                  "label": "Remove",
                  "enabled": true
                },
                "on": {
                  "tap": [
                    {
                      "action": "$remove",
                      "key": "items",
                      "at": {
                        "$expr": "item_index"
                      }
                    }
                  ]
                }
              }
            ]
          }
        ]
      },
      {
        "type": "Button",
        "id": "add",
        "properties": {
          "label": "Add an espresso",
          "enabled": {
            "$expr": "state.count < 5"
          }
        },
        "on": {
          "tap": [
            {
              "action": "$append",
              "key": "items",
              "value": {
                "id": "espresso",
                "name": "Espresso",
                "cents": 250
              }
            }
          ]
        }
      }
    ]
  },
  "on": {
    "appear": [
      {
        "action": "$set",
        "key": "seen",
        "value": true
      }
    ]
  },
  "watch": {
    "items": [
      {
        "action": "$set",
        "key": "count",
        "value": {
          "$expr": "$length(state.items)"
        }
      }
    ]
  }
}
What it usesWhat it is
$repeat with keyOne template per element of an array. The key makes a row's identity follow the element, so removing the first row does not renumber the rest. Without it, rows are identified by position.
$append, $remove, $updateChange one element of a list in state: add, drop, or set one field. Inside the template, item_index is the row's position at the moment of the tap, so a row edits itself.
watchAction lists that run when a state key changes, as part of the change. Here it keeps count in step with the list. A watch never triggers another watch, so there is no cascade to reason about.
onLifecycle bindings: appear when the host says the view is on screen, disappear when it leaves. The host container delivers both; Milano infers nothing.
formatMoney(...)A function your app computes, declared in the vocabulary and called by its bare name. The contract's own functions carry a $ ($concat, $str, $length), so yours can be named anything, round included, without either shadowing the other.

Declaring and using your own functions

Formatting is the usual reason: money, dates, plurals, units. The document should not carry locale rules, and Milano should not guess them, so the app computes them. Declare the function in the vocabulary, with its argument types and what it returns:

JSONmilano/vocabulary.json
{
  "functions": {
    "formatMoney": { "arguments": ["int", "string"], "returns": "string" }
  }
}

Then give the engine one function handler. It answers every function the vocabulary declares, for every view that engine builds:

Swift
// The engine takes one function handler; it answers every function the
// vocabulary declares, for every view built from this engine.
let engine = try MilanoEngine(
    vocabularyJson: vocabulary,
    registry: PromoBridge.registry(),
    functionHandler: MilanoClosureFunctionHandler { call in
        switch call.name {
        case "formatMoney":
            let cents = call.arguments[0].intValue ?? 0
            let currency = call.arguments[1].stringValue ?? "EUR"
            let formatter = NumberFormatter()
            formatter.numberStyle = .currency
            formatter.currencyCode = currency
            let amount = NSNumber(value: Double(cents) / 100)
            return .string(formatter.string(from: amount) ?? "")
        default:
            return .null
        }
    }
)

Documents then call it like any other function: formatMoney(item.cents, 'EUR'). The gate checks the call against the declaration, so a wrong argument count or type fails the build rather than the screen. A function must be pure over its arguments: the same arguments always give the same value. That is why the locale is passed in from context rather than read inside the handler, and it is what lets the engine call it whenever a dependency changes.

While you are still writing documents

npm run check has no app to ask, so it answers every declared function with the zero value of its return type: an empty string here. The document is still fully type-checked; only the formatting is missing. The playground answers a small library of functions if you want to see values.

The rest, in one place

FeatureWhat it is forGuide
Typed results and failuresAn action's handler answers with a value the document reads as result, or fails with a reason it reads as failure, so error copy lives in the document.Writing documents
Dispatch identityEvery dispatched action carries a process-unique dispatchId: the idempotency key for the request your handler makes.Creating a bridge
Document replacementview.replace(document) swaps a live view's document, keeping the state whose declaration is unchanged: hot reload, or a refreshed document, without losing what the user typed.Creating a bridge
Capability grantsA surface can narrow the actions a document may dispatch, or declare extra ones for itself, so a banner cannot reach what a settings screen can.Creating a bridge
Context that changesA context handle pushes new values into every live view: sign-in, feature flags, locale.Guidelines
AnalyticsImpressions, taps, dispatches, and outcomes arrive as structured records, with no document or renderer involvement.Analytics
GuardrailsEvery rejection rule, every runtime occurrence, the limits, and the unknown-type policies.Guardrails
9

Change the banner

Edit milano/documents/banner.json: new copy, a different image, a second line of text. Run npm run check, rebuild the app, and the change is on screen. No app code moved, because the app bundles the producer folder's files: from here on, changing what the banner says is a change in milano/, checked by the CLI, and the app is rebuilt with it.

When the vocabulary itself changes (a new component, a new property), bump its version and let the CLI say whether the bump is right before the app team depends on it:

Terminal
$ npx milano diff vocabulary-1.0.0.json vocabulary.json
ADDITIVE  Text property maxLines added
verdict: ok (0 breaking, 1 additive)

Additive changes need a minor bump, breaking ones a major, and documents declare the minimum they need, so an older app and a newer document never meet by accident. Regenerate the bindings, add the renderer for anything new, and the compiler walks you through the rest.

The same document, everywhere

Switch the platform above and read steps 4 to 7 again: the vocabulary, the banner, and the check never changed. That is the contract: one document, validated once, rendered by whatever the app is built with.

Where next