Portals

Portals are authenticated URLs that expose web servers running inside an orb, so you can open a dev server or preview app straight from the thread’s Portal tab.

An Amp thread with its Portal tab open beside it

You can navigate in portals just like in browser. And you can also annotate what you see and send your comments directly to the agent:

The easiest way to get started is to ask Amp:

Start the dev server and give me a portal link.

Put Each Responsibility in the Right File

FilePurpose
.agents/setupPrepare a new orb by installing dependencies, generating files, and checking required tools
.agents/resumePerform optional, quick authentication or repair work after activation and each wake
.amp/services.yamlDeclare long-running services and their portal links
.amp/portals/*.jsonStore generated portal links for the current orb and thread

Use .agents/setup for work a fresh orb needs before an agent starts. Make the script executable and safe to run more than once:

#!/usr/bin/env bash
set -euo pipefail

npm ci
chmod +x .agents/setup

Do not start a development server or run amp orb services ensure from .agents/setup. Setup prepares the environment. .amp/services.yaml owns the lifecycle of long-running services.

Most repositories do not need .agents/resume for portals. Add it only for quick, idempotent repair work that Amp cannot supervise as a service, such as reconnecting a tunnel. Amp starts this hook after initial orb activation and whenever the orb wakes. Amp waits for up to 10 seconds before it lets the agent continue. Do not install dependencies or restart declared portal services in this hook.

Read Customizing Orbs for complete setup and resume examples and their log locations.

Amp generates .amp/portals/*.json when it ensures a service. Edit .amp/services.yaml, not the generated file, when you want to change a declared service or its links. Ignore generated portal state in Git:

.amp/portals/

Define Services

Commit .amp/services.yaml so every orb knows how to run the repository’s development services:

services:
  web:
    command: pnpm dev -- --host 0.0.0.0 --port "$PORT"
    cwd: app
    health: /healthz
    env:
      API_MODE: development
    review: true
    agent: control
    portal:
      url: /
      title: App
      description: Use the seeded development account.
    portals:
      - url: /admin
        title: Admin
        description: Open the administration tools.

The file needs a nonempty top-level services mapping. Service names can contain lowercase letters, numbers, and hyphens. A name must start with a letter or number and can have up to 32 characters.

Each service supports these fields:

FieldPurpose
commandRequired shell command. It runs through a login shell and must listen on $PORT.
cwdWorking directory relative to the repository root. It defaults to the repository root.
portFixed local port from 1 to 65535. Omit it to let Amp choose a free port that stays the same across restarts.
envString environment variables for the command. Values can refer to another service’s public URL.
healthHTTP path Amp requests after the port opens. HTTP 2xx and 3xx mean ready.
portalPrimary portal link. Use true for / with the service name, or provide link details.
portalsMore portal links for the same service. Each entry needs url and title.
reviewWhether Amp injects the review widget. It defaults to true.
agentAgent access to the portal. Use off, observe, or control. It defaults to off.

A portal url can be a path that starts with / or an absolute HTTP or HTTPS URL. A primary portal mapping defaults to / and the service name when you omit url or title.

Amp manages PORT, PUBLIC_URL, and AMP_THREAD_ID, so you cannot set them inside env. Every service receives PORT and AMP_THREAD_ID. A service with at least one portal link also receives PUBLIC_URL.

$AMP_USER_EMAIL is the only placeholder supported in a portal entry. Amp replaces it in the url, title, and description. It URL-encodes the value when it appears in url.

Start Declared Services

Run every declared service from the repository root:

amp orb services ensure

Ensure reads .amp/services.yaml, checks each service, starts anything missing, and waits for readiness. It then writes the Portal tab state and prints the exact portal URLs. The Portal tab also runs ensure when you open a declared portal that has not started yet.

Use the printed URL. Do not use a raw localhost URL, construct a portal hostname, or save one in configuration. When an application needs its public origin, read PUBLIC_URL at runtime.

For scripts that need structured service status, run:

amp orb services ensure --json

Add Health Checks

Without health, Amp considers a service ready when its port accepts a connection. Set health when the application needs more time after opening the port:

services:
  web:
    command: pnpm dev -- --host 0.0.0.0 --port "$PORT"
    health: /healthz
    portal: true

Amp sends a GET request to the path. HTTP 2xx and 3xx responses pass the check. A configured portal URL can exist even when readiness fails, so the URL alone does not prove that the app responded.

Connect Declared Services

One service can use another service’s final public URL through an env reference:

services:
  api:
    command: go run ./cmd/api
    env:
      FRONTEND_URL: ${services.web.publicURL}
  web:
    command: pnpm dev -- --host 0.0.0.0 --port "$PORT"
    portal: true

Amp starts the referenced service first and replaces the reference with its final URL. The referenced service must have a portal. References to missing services and dependency cycles are rejected.

For separate frontend and API services, expose the frontend and route API requests through the frontend development server’s proxy. Browser requests between two portal hosts are not supported because CORS preflight requests do not carry portal authentication.

Use a folder to group related links while leaving other links at the top level:

services:
  web:
    command: pnpm dev
    portal: true
    portals:
      - folder: Marketing site
        links:
          - url: /home
            title: Homepage
          - url: /pricing
            title: Pricing
      - url: /storybook
        title: Storybook

amp orb services ensure also writes the Portal tab’s links to .amp/portals/*.json. To add custom links, write a separate manifest by hand. Service-generated manifests are an implementation detail, so edit .amp/services.yaml instead of editing them. Get each custom URL from amp orb portal 3000 (pass the local port) instead of hardcoding the domain format:

{
	"links": [
		{
			"label": "App",
			"url": "https://t-…-p3000.onamp.dev",
			"note": "Use the seeded test account."
		},
		{
			"folder": "Admin",
			"links": [
				{ "label": "Users", "url": "https://t-…-p3000.onamp.dev/admin/users" },
				{ "label": "Settings", "url": "https://t-…-p3000.onamp.dev/admin/settings" }
			]
		}
	]
}

A link’s optional note is shown as an info popover next to it. A folder can contain links one level deep, and each folder name must be unique within a service. The Portal tab shows the folder as a menu and keeps other links at the top level.

Manage Services

Use these commands to inspect and control supervised services:

amp orb service list
amp orb service status web
amp orb service logs web
amp orb service restart web
amp orb service stop web

Restart a declared service after changing its command, dependencies, or source when the development server does not reload that change itself. Restart reads the latest .amp/services.yaml and keeps the service’s port and portal configuration.

Do not use nohup, setsid, a background &, or tmux to keep a portal server alive. Processes started that way can be stopped when Amp updates or the orb resumes. Use a supervised service.

Start a One-Off Service

Use amp orb service start for a service needed only in the current thread:

amp orb service start app \
  --command 'npm run preview -- --host 0.0.0.0 --port "$PORT"' \
  --portal \
  --title 'App' \
  --description 'Use the seeded development account.'

The outer quotes keep the shell from expanding $PORT before Amp supplies it. You can also pass --cwd or --port. With --portal, Amp waits for the listener, detects HTTP or HTTPS, writes .amp/portals/<name>.json, and prints the URL.

Amp waits up to 60 seconds for that port, so a hardcoded wrong port causes a long wait followed by NOT RESPONDING and exit status 1. The reported port is the app’s internal port. The p<number> in the public URL may differ because it identifies Amp’s Portal proxy. A manifest and URL may still be printed after a readiness failure. They mean the link was configured, not that the app responded. Check amp orb service logs <name>.

The start command rejects a name already declared in .amp/services.yaml. Use ensure or restart for declared services. If a one-off service will be useful in future orbs, move its configuration into .amp/services.yaml.

amp orb portal <port> is a lower-level command for a server that is already listening. It creates or prints a portal URL, but it does not start or supervise the server. Prefer declared services for repository development servers.

Choose a Portal Hostname

Portal URLs are generated subdomains of onamp.dev by default. To give one portal a specific hostname, run amp orb portal <port> --hostname <hostname> with a managed hostname like <name>--<namespace>.onamp.dev or a hostname on a custom domain from your Domains settings page. The hostname applies only to that port.

A workspace can also select Make Default for Portals on an active domain on its Domains page. New portal links for the workspace’s threads then use subdomains of that domain, such as t-<thread>-p<port>.portal.example.com, so your people only need to trust links on your own domain. Existing URLs keep working.

Reuse a Custom Hostname

A custom hostname stays assigned when you archive its thread. To move it to a portal in another thread, run this command inside the new thread’s orb:

amp orb portal <port> --hostname <hostname> --take-over

Amp moves the hostname from the other thread portal to the current portal. You can take over only a hostname that the new thread can use. Without --take-over, Amp reports that the hostname is already in use.

Review and Comment

Open the portal from the thread’s Portal tab. The floating review button lets you select an element and send a comment to the thread. Amp receives your comment with the selected text and page location. It also receives the source location when the app can provide one.

Portal comments work well for visual fixes because you can point at the exact part of the running app instead of describing its location.

The review widget is enabled by default. Disable it for every page in one service with:

services:
  web:
    command: pnpm dev -- --host 0.0.0.0 --port "$PORT"
    review: false
    portal: true

Disable it for one response with this header:

x-amp-review-widget: off

Set agent: observe to let the agent read browser logs and errors. Set agent: control to also let the agent request page inspection or control. A person in the portal tab must still approve control. Disable agent access for one response with x-amp-portal-agent: off.

Use target="_amp" on a link to let a person leave the Portal pane and open an Amp page in the containing Amp app:

<a href="https://ampcode.com/threads/T-..." target="_amp">Open the thread in Amp</a>

Handle Development Sign-In

Vite

Amp sets AMP_ORB=1 inside every orb. When that variable is set, configure the dev server to accept requests for any host because each portal has a generated hostname. Vite uses true rather than '*' for this setting:

export default defineConfig({
	server: {
		allowedHosts: process.env.AMP_ORB ? true : undefined,
	},
})

Keep the existing configuration and plugins when adding this setting. Do not allow every host outside an orb.

Laravel

Start Laravel from .amp/services.yaml, where $PUBLIC_URL is available. Assign it at runtime; never save a generated portal hostname:

services:
  web:
    command: >-
      php artisan optimize:clear &&
      APP_URL="$PUBLIC_URL" ASSET_URL="$PUBLIC_URL"
      php artisan serve --host=0.0.0.0 --port="$PORT"
    portal: true

Amp supplies the public Portal Host and standard forwarded HTTPS headers. Use Laravel’s normal trusted-proxy handling; do not inspect provider-specific headers or replace Request::capture(). Clear Laravel configuration and any full-response cache before listening whenever the public origin changes.

OAuth, email links, and password managers make repeated development sign-in difficult for both agents and reviewers. Add a development-only route such as:

GET /__dev/log-me-in/<email>?returnTo=<path>

The route should find the user with the given email address, or create the user if absent. Create a session and set the application’s normal session cookie, using the same cookie name and options as the real sign-in flow. Redirect to a relative path on the same origin. Reject absolute and protocol-relative return URLs to prevent open redirects. If returnTo is missing or not safe, redirect to /. Return 404 outside local development by checking both the framework development flag and that NODE_ENV is not production. Do not include the route in production builds.

Put an example URL and test account in AGENTS.md so agents can discover them. A request made with curl does not sign in the browser, so open the URL in the browser session used for testing.

You can make the development login the primary portal link without hardcoding an email:

services:
  web:
    command: pnpm dev -- --host 0.0.0.0 --port "$PORT"
    portal:
      url: /__dev/log-me-in/$AMP_USER_EMAIL?returnTo=/
      title: App
      description: Sign in as $AMP_USER_EMAIL.

Access and Lifecycle

Portal access is separate from application authentication. By default, only people who can view the thread can access its portals. A multiplayer participant can use the portal for as long as their access to that thread remains active.

To share a portal with anyone, open Portal Options, select Make Public, and choose 1 hour, 3 hours, 1 day, or 7 days. Anyone with the link can open the portal during that time without signing in to Amp. Public access applies only to that port. The public link does not grant access to the thread or its review tools.

Amp sets X-Amp-Authenticated on requests sent through the external portal proxy. The header includes the following tokens, separated by commas:

  • amp-user=yes|no says whether the visitor is a registered Amp user. The user may be outside the workspace.
  • workspace-member=yes|no says whether the user belongs to the workspace that owns the thread.
  • collaborator=yes|no is yes for the thread creator. For a multiplayer thread, it is also yes for anyone who can currently contribute to the thread.

The possible values are:

  • Unauthenticated visitor: amp-user=no, workspace-member=no, collaborator=no
  • Amp user outside the workspace: amp-user=yes, workspace-member=no, collaborator=no
  • Creator of a personal thread: amp-user=yes, workspace-member=no, collaborator=yes
  • Workspace member: amp-user=yes, workspace-member=yes, collaborator=no
  • Workspace member for a portal of a multiplayer thread who can currently contribute: amp-user=yes, workspace-member=yes, collaborator=yes

In most cases, you’ll just want to check for string-includes workspace-member=yes to perform access control.

Amp also sends these user identification headers when the user is a workspace member or collaborator:

X-Amp-User-ID: user_abcd1234
X-Amp-User-Display-Name: Ada Lovelace
X-Amp-User-Email: ada@example.com
X-Amp-User-URL: https://ampcode.com/@ada

Do not add access to .amp/services.yaml. The current format does not support it. Amp manages portal access separately from the service definition.

Opening a portal wakes a paused orb. The orb resumes billing while the request and services are active, then automatically pauses again after five minutes without activity. Archiving the thread also pauses its orb.

Inside the owning orb, requests to the thread’s portal URL go directly to the local service. This means browser automation, curl, Node.js, and Python can use PUBLIC_URL without a second portal sign-in. This bypasses only Amp’s portal authentication. It does not bypass authentication in the application itself, and it does not test the external viewer sign-in flow. The app receives the same public Host and standard forwarded HTTPS headers as through the external portal path.

Troubleshooting

Vite Rejects the Portal Hostname

If Vite returns Blocked request. This host (...) is not allowed, merge the Orb-only server.allowedHosts setting shown above into the user’s existing vite.config.js. Keep all existing config and plugins.

Keep the service’s fixed port in .amp/services.yaml. After changing the Vite config, restart the service or run amp orb services ensure, then test through the actual Portal hostname. To check Vite directly, curl the local service with the Portal hostname in the Host header.

First rerun ensure. It repairs a missing supervised service and its portal without starting a duplicate:

amp orb services ensure

If the service does not become ready, inspect its status and logs:

amp orb service status <name>
amp orb service logs <name>

Check these common causes:

  • The command listens on a hardcoded port instead of $PORT.
  • The process exits after starting a child server.
  • The configured health path returns an error or never becomes ready.
  • The development server rejects the generated portal hostname.
  • The app expects its own login, but the browser opened only the portal URL.

A printed portal URL means Amp configured the public route. It does not prove that the service passed its readiness check. After fixing the command or configuration, run amp orb service restart <name> and open the exact URL printed by Amp.