Local services

A desktop miniapp can ship a native HTTP service inside its package. The AI Platform verifies the locked service files, starts the service in a reviewed sandbox, waits for its readiness endpoint, and returns a host-selected loopback origin through sdk.services.v1.

Use a local service only when browser or QuickJS code cannot perform the work. The service is part of the signed package release. It is not a general process launcher, and miniapp code never receives a process handle or chooses a port.

Declare the service and its authority

Set compatibility.tapHost to a range that excludes hosts before 2.0.5. Keep compatibility.tapSdk pinned to the exact SDK version used to build and test the package. The examples below use SDK 0.5.3.

Add three declarations to manifest.tap.json:

  1. the canonical local-service.run action;
  2. one local.service contribution with an exact effect naming itself; and
  3. the same action and effect on each desktop surface that calls the service.

This fragment shows a service named document-indexer-service. Merge it into a complete descriptor generated by create-tap-miniapp.

{
  "compatibility": {
    "tapSdk": "0.5.3",
    "tapHost": ">=2.0.5"
  },
  "contributions": [
    {
      "kind": "permission.catalog",
      "id": "permissions",
      "apiVersion": 1,
      "options": {
        "actions": [
          {
            "id": "local-service.run",
            "resource": "local-service",
            "scopes": ["user"],
            "directActors": ["human"],
            "delegatedActors": [],
            "autonomyCeiling": "do",
            "consent": "reusable",
            "risk": "consequential"
          }
        ]
      }
    },
    {
      "kind": "local.service",
      "id": "document-indexer-service",
      "apiVersion": 1,
      "lifecycleScope": "installation",
      "targets": {
        "desktop": {
          "runtime": "host-declarative"
        }
      },
      "authorization": {
        "allOf": ["local-service.run"],
        "effects": [
          {
            "kind": "local-service",
            "resources": ["document-indexer-service"]
          }
        ]
      },
      "options": {
        "protocol": "http",
        "launchers": {
          "aarch64-apple-darwin": {
            "runtimeRoot": "services/document-indexer/aarch64-apple-darwin",
            "executable": "document-indexer",
            "args": [
              "serve",
              "--host",
              "${tap.listenHost}",
              "--port",
              "${tap.listenPort}",
              "--allowed-host",
              "${tap.packageOriginHost}"
            ],
            "env": {
              "TAP_RUNTIME_DIR": "${tap.runtimeDir}"
            },
            "sandboxProfile": "local-http-service",
            "readiness": {
              "path": "/health",
              "timeoutMs": 30000,
              "intervalMs": 250
            },
            "restart": {
              "maxAttempts": 3,
              "windowMs": 60000,
              "backoffMs": 1000
            }
          }
        }
      }
    },
    {
      "kind": "ui.surface",
      "id": "document-indexer-surface",
      "apiVersion": 1,
      "lifecycleScope": "mount",
      "targets": {
        "desktop": {
          "expose": "./ui/desktop",
          "runtime": "webview"
        }
      },
      "authorization": {
        "allOf": ["local-service.run"],
        "effects": [
          {
            "kind": "local-service",
            "resources": ["document-indexer-service"]
          }
        ]
      },
      "options": {
        "displayName": "Document indexer",
        "placement": "workspace-left",
        "scope": "workspace",
        "instancePolicy": "per-workspace",
        "persistence": "none"
      }
    }
  ]
}

Keep the starter's lifecycle contract alongside these contributions. A desktop surface selecting ./ui/desktop with the webview runtime requires lifecycle.lifecycleExpose, a matching target expose such as ./tap/lifecycle, and the same webview runtime on that lifecycle expose. SDK 0.5.3 validates this relationship before packaging. Contribution IDs such as document-indexer-service may use ASCII letters, digits, underscores, and hyphens; dotted IDs are rejected.

Copy the complete service runtime into the desktop target output under runtimeRoot before assembleTapPackage runs. Assembly locks every regular file in that subtree and rejects missing executables, symlinks, unsafe paths, and collisions.

The host replaces only these launcher placeholders:

PlaceholderValue
${tap.listenHost}The loopback address the service must bind.
${tap.listenPort}The host-selected port.
${tap.packageOriginHost}The exact package host the service should accept in Host and Origin checks.
${tap.runtimeDir}A private, generation-scoped runtime directory.

Launcher arguments and environment variable names are specific to your executable. Do not bind a service to 0.0.0.0, inherit the host environment, or accept arbitrary Host and Origin values.

local-http-service is the general HTTP sandbox. The apple-simulator-control profile is available only for reviewed macOS services that need CoreSimulator access. Declare a launcher for every desktop target you support. The SDK rejects an Apple-only sandbox profile on another platform.

Package the Baguette reference service

Baguette v0.1.85 is the reference integration for an Apple Silicon macOS service. It is an upstream project, not a miniapp product owned or shipped by The AI Platform. Use the pinned release as a packaging example for services that need CoreSimulator.

The pinned integration requires:

  • Apple Silicon macOS 15;
  • the full Xcode application installed in /Applications and selected with xcode-select;
  • the iPhone Simulator platform and at least one available simulator device; and
  • Miniapp SDK 0.5.3 with compatibility.tapHost set to >=2.0.5.

Check the machine before testing the package:

uname -m
sw_vers -productVersion
xcode-select --print-path
xcrun simctl list devices available

uname -m must report arm64. The selected developer directory must belong to a supported Xcode bundle in /Applications, and simctl must list an available device. Command Line Tools alone do not provide CoreSimulator.

Download and verify the exact upstream archive before extracting it into the package source tree:

BAGUETTE_ARCHIVE='baguette_v0.1.85_macOS_arm64.tar.gz'
BAGUETTE_ARCHIVE_URL="https://github.com/tddworks/baguette/releases/download/v0.1.85/${BAGUETTE_ARCHIVE}"
BAGUETTE_ARCHIVE_SHA256='98da5b6392cff620da4efed94c773f507f38505f77b0db7a2890e6684e3c50ed'
BAGUETTE_STAGING="$(mktemp -d)"
BAGUETTE_RUNTIME='services/baguette/aarch64-apple-darwin'
trap 'rm -rf -- "$BAGUETTE_STAGING"' EXIT

curl --proto '=https' --tlsv1.2 --fail --location \
  --output "$BAGUETTE_STAGING/$BAGUETTE_ARCHIVE" \
  "$BAGUETTE_ARCHIVE_URL"

test "$(wc -c < "$BAGUETTE_STAGING/$BAGUETTE_ARCHIVE" | tr -d '[:space:]')" = '4141386'
printf '%s  %s\n' \
  "$BAGUETTE_ARCHIVE_SHA256" \
  "$BAGUETTE_STAGING/$BAGUETTE_ARCHIVE" | shasum -a 256 -c -

tar -xzf "$BAGUETTE_STAGING/$BAGUETTE_ARCHIVE" -C "$BAGUETTE_STAGING"
mkdir -p "$BAGUETTE_RUNTIME"
cp -R \
  "$BAGUETTE_STAGING/baguette-v0.1.85-macOS-arm64/." \
  "$BAGUETTE_RUNTIME/"
test -x "$BAGUETTE_RUNTIME/Baguette"

The release archive supplies the executable and runtime resources, but it does not contain license or provenance files. Before assembly, add the upstream Apache-2.0 LICENSE, the notices and license texts required by the resolved Swift, web, and virtual-camera dependencies, and a machine-readable provenance record under runtimeRoot. Record at least the source URL, release commit, archive name, byte count, SHA-256 digest, and resolved dependency versions. Review these files for the package you publish; do not copy TAP's test-fixture notice verbatim.

Keep the complete Swift bundle, web assets, virtual-camera library, and those legal and provenance files under runtimeRoot. Extract the archive before assembly. Do not package the source archive, download Baguette at runtime, or replace the pinned bytes through a mutable URL.

Add this contribution after declaring the canonical local-service.run action and exact service effect described above:

{
  "kind": "local.service",
  "id": "baguette-service",
  "apiVersion": 1,
  "targets": {
    "desktop": {
      "runtime": "host-declarative"
    }
  },
  "lifecycleScope": "installation",
  "authorization": {
    "allOf": ["local-service.run"],
    "effects": [
      {
        "kind": "local-service",
        "resources": ["baguette-service"]
      }
    ]
  },
  "options": {
    "protocol": "http",
    "launchers": {
      "aarch64-apple-darwin": {
        "runtimeRoot": "services/baguette/aarch64-apple-darwin",
        "executable": "Baguette",
        "args": [
          "serve",
          "--host",
          "${tap.listenHost}",
          "--port",
          "${tap.listenPort}",
          "--allowed-hosts",
          "${tap.packageOriginHost}"
        ],
        "env": {},
        "sandboxProfile": "apple-simulator-control",
        "readiness": {
          "path": "/simulators.json",
          "timeoutMs": 30000,
          "intervalMs": 250
        },
        "restart": {
          "maxAttempts": 3,
          "windowMs": 60000,
          "backoffMs": 1000
        }
      }
    }
  }
}

Assembly locks every extracted file and verifies that Baguette exists inside the declared runtime root. The package intentionally has no launcher for Intel macOS, Linux, or Windows, so those hosts report local_service.unsupported_platform.

Start Baguette and read its readiness resource through the returned origin:

import { sdk } from '@theaiplatform/miniapp-sdk/sdk';

const services = sdk.services?.v1;
if (!services) {
  throw new Error('Baguette requires a desktop host with local services.');
}

const running = await services.ensureRunning({
  contributionId: 'baguette-service',
});
const response = await fetch(
  new URL('/simulators.json', running.endpoint.origin),
);
if (!response.ok) {
  throw new Error(`Baguette returned ${response.status}.`);
}
const simulators = await response.json();

The host supplies the loopback bind address, port, and exact package-origin hostname. Baguette uses that hostname for its Host and Origin checks. Do not retain running.endpoint.origin after the service generation changes.

Start and call the service

Feature-detect sdk.services.v1 because mobile and older desktop hosts do not install the capability.

import { sdk } from '@theaiplatform/miniapp-sdk/sdk';

const services = sdk.services?.v1;
if (!services) {
  throw new Error('This feature requires a desktop host with local services.');
}

const running = await services.ensureRunning({
  contributionId: 'document-indexer-service',
});

const response = await fetch(new URL('/documents', running.endpoint.origin));
if (!response.ok) {
  throw new Error(`Document service returned ${response.status}.`);
}

ensureRunning is idempotent. It installs and starts the declared service when needed, waits for the signed readiness deadline, and returns the exact origin for the current generation. getStatus reads state without starting or restarting anything.

The HTTP origin can also host service-defined WebSocket routes:

const eventsUrl = new URL('/events', running.endpoint.origin);
eventsUrl.protocol = eventsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
const events = new WebSocket(eventsUrl);

Do not retain an origin across a status change. A restart creates a new opaque generation and may select a new port.

Test the service in Test Lab

Native services are disabled in Test Lab by default. Enable them only on a Live TAP profile with the exact service effect. The matrix entry must also declare action:local-service.run.

The descriptor vocabulary uses "mode": "live". Inside the attached Rstest session, the canonical SDK projection is tap.mode === "live-tap"; do not compare the runtime fixture with the descriptor literal.

{
  "$schema": "https://the-ai-platform.dev/schemas/tap.test.schema.json",
  "schemaVersion": 2,
  "packageId": "com.example.document-indexer",
  "configPath": "rstest.tap.config.ts",
  "testMatch": ["tests/e2e/**/*.tap.ts"],
  "profiles": {
    "document-indexer-desktop-live": {
      "surface": "document-indexer-surface",
      "target": "desktop",
      "mode": "live",
      "permissionScenario": "default",
      "deniedActions": [],
      "fixtureFiles": [],
      "httpRouteFiles": [],
      "requiredOrigins": [],
      "requiredEffects": ["effect:local-service:document-indexer-service"],
      "nativeServices": true,
      "credentialSlots": [],
      "environment": {
        "viewport": { "width": 1280, "height": 720 },
        "locale": "en-US",
        "timezone": "UTC",
        "theme": "light",
        "reducedMotion": true,
        "seed": 1,
        "fixedNow": "2026-01-01T00:00:00Z"
      },
      "artifacts": {
        "trace": "failure-only",
        "screenshots": "always"
      }
    }
  },
  "matrix": [
    {
      "id": "document-indexer-desktop-live",
      "profile": "document-indexer-desktop-live",
      "capabilities": [
        "action:local-service.run",
        "effect:local-service:document-indexer-service"
      ]
    }
  ],
  "waivers": []
}

Run the descriptor checks before connecting the project:

pnpm exec tap-miniapp-test doctor
pnpm exec tap-miniapp-test matrix
pnpm exec tap-miniapp-test scaffold --check

Open Settings > Miniapps > Installed, select the package, and open Tests. Connect the source and emitted package roots, then run the Live TAP row. The authority review shows Native services as Enabled for this session only and lists the exact action and service effect.

Test Lab copies and re-verifies the locked package assets for that run. Service calls use the session supervisor and never create a production grant. The host stops the complete service process tree and removes the session materialization when the run finishes, is canceled, or its package snapshot changes. If the bound build changed, reconnect the test project before running it again.

The Rstest adapter observes browser requests for diagnostics but does not intercept them. A host-selected local-service loopback origin cannot be listed ahead of time in requiredOrigins; leave that list for exact, static sdk.http.request origins. The native-service action and effect authorize the launch, while the packaged service must still enforce the host-provided Host and Origin values on its HTTP routes.

An Untrusted package can use this reviewed test session. It remains denied from production local-service authority.

To cover denial behavior, use a separate profile with nativeServices: false, permissionScenario: "deny:local-service.run", and deniedActions: ["local-service.run"]. A profile that enables native services cannot deny the same action.

Inspect and restart an installed service

Open Settings > Miniapps > Installed, select the package, and stay on Overview. The Local services panel shows each contribution's supported platform, current state, generation, and stable error code.

Installed Miniapps Local services panel showing running, stopped, and failed service states with local log and restart actions.
The host exposes bounded status, local diagnostics, and a confirmed Restart action without exposing process IDs, ports, Stop, or Kill controls.

Choose View local logs to read the bounded tail for the current generation. The tail stays on the device, indicates when older output was truncated, and is never uploaded automatically.

Choose Restart for a stopped, running, or failed service. Restarting interrupts current miniapp connections. The host re-verifies and rematerializes the active release before starting a new generation.

Common failures include:

CodeCheck
local_service.unsupported_platformAdd a launcher for the current desktop target.
local_service.permission_deniedReview the exact action and service effect.
local_service.integrity_failedRebuild, reassemble, and reconnect the exact package generation.
local_service.readiness_timeoutVerify the bind placeholders and readiness path, then inspect local logs.
local_service.crash_loopInspect the service exit output and signed restart policy.
local_service.sandbox_unavailableUse a supported reviewed sandbox profile on the selected platform.

Treat unknown local_service.* codes from newer hosts as ordinary operation failures. See Troubleshooting for package activation, CoreSimulator, readiness, and crash-loop checks.