---
name: harbor-apps
user-invocable: false
description: "Provides live Harbor app authoring and versioned deployment. Use deployApp in a TypeScript file published with hrbr exec -f when the user wants a routed public or workspace app, raw HTML page, form, dashboard, report, or app shell backed by direct handlers and/or reusable Harbor jobs."
---

# Harbor Apps

## Use When

- The user wants a routed UI, raw HTML page, form, report, dashboard, or public/workspace URL.
- The app should keep a stable URL while new immutable versions are promoted.
- The app needs app-local state for browser/session interaction.
- The app needs reusable backend work through jobs.
- The app needs plugin data. Publish a job that calls the plugin, then have the app call that job.

## Do Not Use When

- The user only needs a one-off remote run. Use harbor-exec.
- The user wants reusable backend behavior without a route or UI. Use harbor-jobs.
- The user wants plugin calls directly in app handlers. Use a backing job for plugin calls, then invoke it from the app.
- The user wants lifecycle APIs inside app runtime code. App lifecycle is top-level deployApp source published through hrbr exec -f plus inspect/control-plane reads.

## Canonical Authoring

Write one top-level deployApp call in a TypeScript file. Do not import it. Use access: "workspace_member" unless the user explicitly asks for a public app.

    deployApp({
      id: "ops-panel",
      description: "Workspace ops panel.",
      access: "public",
      routes: {
        "GET /": {
          staticHtml: "<!doctype html><html><body><main><h1>Ops Panel</h1></main></body></html>",
        },
      },
    });

Publish and inspect:

    hrbr exec -f ./ops-panel.app.ts
    hrbr inspect 'return await hrbr.apps.inspect({ id: "ops-panel" })'

Reusing the same id publishes the next immutable app version and keeps the stable app URL.

## Route Shapes

Static raw HTML:

    "GET /": {
      staticHtml: "<!doctype html><html><body><h1>Hello</h1></body></html>",
    }

Direct handler:

    "POST /api/save": {
      input: "json",
      output: "json",
      async run(request) {
        const body = await request.json();
        await hrbr.state.set("last", body, { ttl_seconds: 3600 });
        return Response.json({ ok: true });
      },
    }

Job-backed route:

    jobs: {
      search: { name: "exa-search-job" },
    },
    routes: {
      "POST /api/search": {
        input: "json",
        output: "json",
        job: "search",
      },
    }

Mixed app:

- GET / serves raw HTML.
- GET /api/state or POST /api/state uses hrbr.state for app-local UI state.
- POST /api/search calls a job that owns plugin/workspace work.

## Runtime Contract

Use hrbr.state for app-local UI/session state:

    const messages = await hrbr.state.get("messages") ?? [];

Use jobs for reusable or plugin-backed work:

    const result = await hrbr.jobs.exaSearchJob({ query: "Harbor MCP" });

Use staticHtml for raw HTML when no request-time dynamic behavior is needed.

Use direct handlers for request glue, validation, forms, redirects, and app-local state.

Do not put plugin namespace calls in public app route handlers. Public app routes can collect input and read safe app state; privileged plugin writes or third-party calls belong in jobs with explicit workspace authorization and trace evidence.

## Raw HTML Rules

- Escape dynamic values before injecting them into HTML.
- Keep HTML, CSS, and client JS in the app file or in a referenced template copied from assets/templates.
- For forms, set action paths that work on both the app subdomain and path fallback. Relative paths such as action="/api/search" are fine for the app host.
- Browser-test the public URL, not only the publish output.

Minimal escaping helper:

    const escapeHtml = (value) =>
      String(value ?? "").replace(/[&<>"']/g, (ch) => ({
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      })[ch]);

## App Plus Job Pattern

If the app needs plugin data:

1. Use harbor-jobs to publish the plugin-backed job first.
2. Verify the job with hrbr.jobs.camelName(input).
3. Publish the app with a job-backed route or direct handler that invokes hrbr.jobs.camelName(input).
4. Open the app in a browser and test the route from the UI.

Do not skip the job. It gives plugin calls a typed boundary, trace evidence, and a reusable function surface outside the app.

## More Detail

Read only what you need:

- references/app-route-patterns.md: raw HTML, forms, direct handlers, route-job manifests, and URL behavior.
- references/app-job-plugin-combinations.md: public app plus plugin-backed job combinations.
- assets/templates/raw-html-app.ts: static raw HTML app.
- assets/templates/stateful-html-app.ts: raw HTML app with hrbr.state API routes.
- assets/templates/job-backed-plugin-app.ts: app that calls a plugin-backed job.

## Verification

    hrbr exec -f ./my-app.app.ts
    hrbr inspect 'return await hrbr.apps.inspect({ id: "my-app" })'
    curl -i '<app-url>'

Then open the app URL in a browser. Verify:

- The subdomain URL loads, for example https://my-app.apps.tryharbor.ai.
- The path fallback loads when relevant, for example https://apps.tryharbor.ai/<workspace>/<app>.
- Forms and API routes work from the browser.
- Console has no app errors.
- Dynamic plugin data appears only through the backing job.

## Done Criteria

- The file contains exactly one top-level deployApp call.
- Publish happened through hrbr exec -f and produced a run id.
- The app is inspectable and has a stable URL.
- Public/workspace access matches the user request.
- Raw HTML renders in a browser.
- Plugin-backed apps use jobs for plugin calls.
- App runtime code did not invent hrbr.apps.publish(...), hrbr.jobs.publish(...), or direct control-plane methods.
