Build

Attach to One Chip and the Attached Device Becomes the Terminal — POS, Kiosk and Kitchen Screen at Once

One server stood up beside the payment terminal already in the shop, with a tablet, a PC and a kitchen screen attached. No app was built for any of the three, and each became a kiosk, a POS and a kitchen display. The card authorisation path was not touched by a single line. Full code, run log, and the three screens exactly as rendered.

By makemind · Jul 30, 2026

Have you ever counted the terminals in a café? An order kiosk, a counter POS, a card reader, paper tickets going to the kitchen, and a stock ledger. Plenty of terminals, and they do not speak to each other. So a person joins them up. Staff read the kiosk order and shout it to the kitchen, payment happens on yet another machine, and stock is reconciled by hand at night.

Adding one more terminal cannot be the answer. The payment terminal already in the shop simply does not know how to share a screen.

This article is a record of standing one server up beside that terminal, attaching a tablet, a PC and a kitchen screen — and getting each attached device to become a kiosk, a POS and a kitchen display. No app was built for any of the three devices. And the card authorisation path was not touched by a single line. Every screen below is a capture of pixels really rendered from a real run.

The result first — three screens

A customer orders on the tablet.

Self order kiosk — three items in the basket and the Bakery tab showing, 163.00 in total. Real render capture
Self order kiosk — three items in the basket and the Bakery tab showing, 163.00 in total. Real render capture

At the same moment, those orders are up on the kitchen screen.

Kitchen display — #101 Flat White, #102 Cold Brew and #103 Sourdough loaf from the kiosk, each with how long it has waited
Kitchen display — #101 Flat White, #102 Cold Brew and #103 Sourdough loaf from the kiosk, each with how long it has waited

Charge at the counter and the terminal returns an authorisation.

Counter POS — approval A4101 (4242), round trip 113 ms, takings today 163.00 from one ticket. The next ticket is already building
Counter POS — approval A4101 (4242), round trip 113 ms, takings today 163.00 from one ticket. The next ticket is already building

The three screens are three different definitions served by the same server. There is no separate tablet app, POS app and kitchen app.

The whole picture

terminal_sim (C)                store_server (Dart · mcp_server)         clients ×3
  terminal endpoint  ──serial/usb──▶  owns order and sales state    ──MCP──▶  tablet   /kiosk
  answers only with an approval       serves one app, three screens  ──MCP──▶  PC       /pos
                                      relays requests via the adapter ──MCP──▶ kitchen  /kds

                                      ui://app        the app — routes, theme, navigation
                                      ui://app/info   what a launcher reads first
                                      ui://pages/*    the screens the routes resolve to
PieceWhat it doesWho writes it
Shop server appHolds order, prep and sales state, and serves the screen definition matching the role that connectedThe developer
Terminal adapterPasses an authorisation request to the terminal and takes the answer back. That is allThe developer
Clients ×3Render the definition the server gaveA few lines of role selection
Terminal endpointReturns approvals(simulated in this sample — see below)

Here, the inside of the payment terminal is not our code. Card data, the approval decision and the VAN link all stay inside the terminal's certified secure area. What we do is ask "please approve this amount" and take an answer. That asymmetry is the whole of this article.


① Where one server serves three screens

This is the centre of the article. The server publishes one app for the shop, and a station comes in at one of its routes.

const Map<String, dynamic> applicationDefinition = {
  'type': 'application',
  'id': 'coffee.milllane.store',
  'title': 'Mill Lane Coffee — store',
  'theme': _storeTheme,            // where the three screens declare colour once
  'initialRoute': '/kiosk',
  'routes': {
    '/kiosk': 'ui://pages/kiosk',
    '/pos': 'ui://pages/pos',
    '/kds': 'ui://pages/kds',
  },
  'navigation': {
    'type': 'tabs',
    'items': [
      {'title': 'Order', 'icon': 'shopping_cart', 'route': '/kiosk'},
      {'title': 'Till', 'icon': 'payment', 'route': '/pos'},
      {'title': 'Kitchen', 'icon': 'storefront', 'route': '/kds'},
    ],
  },
  // Once, when the app opens: the menu carries the photographs and changes
  // about as often as the price list.
  'onInit': {'type': 'tool', 'tool': 'menu.list', 'params': {}},
};

final screens = <String, (String, String, Map<String, dynamic>)>{
  'ui://pages/kiosk': ('Self Order Kiosk', 'Customer-facing order screen', kioskDefinition),
  'ui://pages/pos': ('Counter POS', 'Owner-facing sales and payment screen', posDefinition),
  'ui://pages/kds': ('Kitchen Display', 'Kitchen order queue', kdsDefinition),
};

screens.forEach((uri, spec) {
  final (name, description, definition) = spec;
  server.addResource(
    uri: uri,
    name: name,
    description: description,
    mimeType: 'application/json',
    handler: (requestedUri, params) async => ReadResourceResult(
      contents: [
        ResourceContentInfo(
          uri: requestedUri,
          mimeType: 'application/json',
          text: jsonEncode(definition),
        ),
      ],
    ),
  );
});

The substance of "the device becomes the terminal" is this route table. Want another screen — a waiting-number display in the window — that is one line in routes and one in screens. You do not build an app to install on that device.

Keeping the three screens as routes of one app rather than three loose documents has a reason. None of them declares a theme, and all three come out the same colour, because the app declared it once and the routes inherit it. As separate documents that colour would be written three times, and what is written three times eventually disagrees.

The client side is this short. The role is not a screen name but the door it comes in at.

const _role = String.fromEnvironment('ROLE', defaultValue: 'kiosk');

// Take the shop's app. Do not ask for a document made for this device.
final resource = await client.readResource('ui://app');
final definition = jsonDecode(resource.contents.first.text!) as Map<String, dynamic>;

final runtime = MCPUIRuntime();
await runtime.initialize(
  definition,
  launchRoute: '/$_role',                  // where this device stands
  onToolCall: onTool,                      // a tap on the screen becomes a tool call
  pageLoader: (route) async {              // the page a route names, from the same server
    final uri = (definition['routes'] as Map?)?[route] as String? ?? route;
    final page = await client.readResource(uri);
    return jsonDecode(page.contents.first.text!) as Map<String, dynamic>;
  },
);

On the kitchen display it runs with --dart-define=ROLE=kds, on the counter PC with ROLE=pos. The same binary. Nowhere in this file is there code describing what the kitchen screen looks like — the server knows that.

Passing onToolCall to initialize is not incidental either. An application-level onInit fires inside initialize, so on a host that hands the executor over only at render time that first call reaches nothing — and a screen that never received the menu opens quietly empty.

② The terminal adapter — the code most likely to be copied out of this article

Send an authorisation request and wait for the answer. That really is all it does, and that thinness is the argument.

/// Send one request and wait for the answer bearing the same id.
///
/// Matched by id rather than by arrival order, because a link is a stream,
/// not a call stack. A status query slipping in while an authorisation is
/// still in flight must not steal the authorisation's answer.
Future<Map<String, dynamic>> call(
  String tool, [
  Map<String, dynamic> args = const {},
]) async {
  final id = _nextId++;
  final completer = Completer<Map<String, dynamic>>();
  _pending[id] = completer;

  final request = jsonEncode({'id': id, 'tool': tool, 'args': args});
  transcript.add('=> $request');
  _proc.stdin.writeln(request);

  final reply = await completer.future.timeout(
    requestTimeout,
    onTimeout: () {
      _pending.remove(id);
      throw TimeoutException('terminal did not answer $tool', requestTimeout);
    },
  );
  if (reply['ok'] != true) {
    throw StateError('terminal refused $tool: ${reply['error']}');
  }
  return (reply['result'] as Map).cast<String, dynamic>();
}

Let me write down why the timeout is three seconds. That is arithmetic, not taste.

/// How long to wait for the terminal.
///
/// The simulator answers in 70–160 ms. A real authorisation is dominated by
/// the VAN round trip, so the order of magnitude is the same. Three seconds
/// is set deliberately far above that distribution. A timeout here must mean
/// "the terminal is gone," not "it was slower than usual." Tighten it to a
/// few hundred milliseconds and a perfectly good authorisation turns into a
/// failure — and a payment reported as failed that actually went through is
/// the worst outcome this adapter can produce.
final Duration requestTimeout;

And the payment handler. What this code does not do matters more.

handler: (args) async {
  final unpaid = _orders.where((o) => o['paid'] == false).toList();
  if (unpaid.isEmpty) { _notice = 'Nothing to charge'; return _state(); }
  final amount = unpaid.fold<int>(0, (sum, o) => sum + (o['price'] as int));

  final started = DateTime.now();
  try {
    final result = await terminal.call('terminal.authorize', {'amount': amount});
    final elapsed = DateTime.now().difference(started);
    for (final o in unpaid) { o['paid'] = true; }
    _salesTotal += amount;
    _lastApproval = '${result['approvalCode']} (${result['last4']})';
    _lastRoundTripMs = elapsed.inMilliseconds;
  } on TimeoutException {
    // A timeout is not a decline. We do not know what the terminal did,
    // so we say we do not know. We do not decide it failed on our own.
    _notice = 'Terminal did not answer — check the receipt before retrying';
  }
  return _state();
}

It does not read the card. It does not judge whether the customer may pay. It does not connect to the VAN. It counts the amount, asks, and records the answer. Everything that happens between the asking and the answer happens inside certified hardware this sample never opens.

Please note the timeout handling. The most dangerous state in payments is not a decline but not knowing. If the authorisation actually went through and we treat it as a failure, the customer is out of pocket with no order. So when we do not know, we write on the screen that we do not know.

This content requires Developer or above

Sign in and upgrade your plan to continue reading.

View Plans
Twitter