Tutorials

Tutorial: Legacy Web App Agent

Let an agent read an internal web app that has no API, only a login form and HTML pages, and run it every month.

Goal

You have an internal web app with no API. It has a login form, a few HTML pages, and the data your business runs on. You will:

  1. Write an integration that logs in with the user's own account when they connect, and keeps only the session cookie.
  2. Check the call path with one request through the platform's credential proxy.
  3. Teach the agent the layout of the page with a skill.
  4. Build an agent that returns the sales of a period as JSON.
  5. Run it every month.

You write three packages: @acme/intranet (an integration), @acme/intranet-reports (a skill) and @acme/sales-report (an agent). No code runs next to the credential: the model reads the HTML itself.

The example app

The app lives at https://intranet.acme.test. Substitute your own host, fields and paths as you read.

POST /login                                     form fields: zone, username, password
  -> 302 Found, Set-Cookie: SESSIONID=...; Path=/; HttpOnly
  -> 200 with the login form again when the credentials are wrong

GET /reports/sales?from=2026-09-01&to=2026-09-30   with Cookie: SESSIONID=...
  -> 200, an HTML page with <table id="sales">
  -> 302 to /login when the session is missing or expired

How it works

The integration declares a declarative login (connect.login). When someone connects, the platform sends one POST /login with the fields they typed, reads the SESSIONID cookie from the answer and stores only that cookie, encrypted, as the connection's credential. The password is used once and not stored.

At run time the agent calls the integration's api_call tool. The run's sidecar checks the URL against the integration's authorized_uris, adds Cookie: SESSIONID=... and returns the page. The agent sees neither the password nor the cookie. See How integrations work for the model behind this.

Prerequisites

  • An Appstrate instance that can reach the intranet over the network (a self-hosted one when the intranet is on a private network), and an account in it (Get Started quickstart).
  • An API key of a space admin in $APPSTRATE_KEY. Step 5 needs the credential-proxy:call scope, which a key created without scopes already carries when its creator holds it (see API keys).
  • A default model for the organization, as in Model providers.
  • zip and curl. The examples use http://localhost:3000, the address of a development instance.

Steps

Let the instance reach the intranet

An internal host usually resolves to a private address, and Appstrate refuses private addresses by default. Two parties must agree before a call reaches one: the integration names the host literally in authorized_uris (step 2 does), and the operator lists it in EGRESS_ALLOW_INTERNAL_HOSTS. Set it in the instance environment and restart:

EGRESS_ALLOW_INTERNAL_HOSTS=intranet.acme.test

The same list covers the login sent at connect time, which leaves from the API process, and the calls of a run, which leave from its sidecar, so both must be able to reach the host. A listed host is trusted for every organization of the instance. Skip this step when the intranet has a public address. The rules are in Isolation and Security.

Write the integration

Create a folder intranet/ with this manifest.json:

{
  "name": "@acme/intranet",
  "version": "1.0.0",
  "type": "integration",
  "schema_version": "0.3",
  "display_name": "Acme intranet",
  "description": "Internal intranet behind a login form, read through its HTML pages.",
  "source": { "kind": "none" },
  "default_tools": ["api_call"],
  "auths": {
    "primary": {
      "type": "custom",
      "credentials": {
        "schema": {
          "type": "object",
          "required": ["zone", "username", "password"],
          "properties": {
            "zone": { "type": "string", "description": "Tenant zone" },
            "username": { "type": "string", "description": "Intranet user name" },
            "password": { "type": "string", "description": "Intranet password" }
          }
        }
      },
      "connect": {
        "login": {
          "request": {
            "method": "POST",
            "url": "https://intranet.acme.test/login",
            "content_type": "application/x-www-form-urlencoded",
            "body": "zone={{zone}}&username={{username}}&password={{password}}"
          },
          "success_criteria": [{ "condition": "$statusCode == 302" }],
          "outputs": {
            "session_id": { "from": "cookie", "name": "SESSIONID" }
          }
        }
      },
      "authorized_uris": ["https://intranet.acme.test/**"],
      "delivery": {
        "http": {
          "in": "header",
          "name": "Cookie",
          "prefix": "SESSIONID=",
          "value": "{$credential.session_id}"
        }
      }
    }
  },
  "_meta": {
    "dev.appstrate/api": { "auths": { "primary": {} } }
  }
}

What each part does:

  • credentials.schema is the form the user fills in when connecting.
  • connect.login.request is the one request the platform sends. Each {{name}} is replaced by the field of the same name that the user typed.
  • success_criteria says what success looks like. The platform does not follow redirects on this request, and without criteria only a 2xx counts as success, so declare the 302 the app sends. A wrong password returns 200 with the form again, and the connection is refused. A criterion is one <expression> == <value> comparison, with a string value in single quotes ($response.body#/status == 'ok'). A form the platform cannot evaluate, such as a compound condition or an unquoted string, is refused when the integration is imported or saved.
  • outputs names what to keep from the answer: here the value of the SESSIONID cookie in Set-Cookie. Only the outputs are stored, and each becomes a credential field, {$credential.session_id}.
  • delivery.http says how the sidecar sends it: a Cookie header. in: "header" is the only placement implemented today, and the import refuses in: "cookie".
  • authorized_uris lists the only URLs that may receive the cookie and the login request. It must be literal: an auth that declares connect cannot use a {$credential.<field>} template there, so the host cannot be a connection field. Write one integration per intranet host.
  • _meta["dev.appstrate/api"] gives the integration its api_call tool.

Import it

The archive is a zip with manifest.json at its root:

cd intranet && zip ../intranet.afps manifest.json && cd ..

curl -X POST "http://localhost:3000/api/packages/import" \
  -H "Authorization: Bearer $APPSTRATE_KEY" \
  -F file=@intranet.afps
# {"packageId":"@acme/intranet","type":"integration","version":"1.0.0"}

The import validates the manifest, and the integration is active in the key's space. Custom integrations covers the other ways to author and publish one.

Connect an account

Each person connects with their own intranet account. The interactive way is the hosted form: this returns a single-use link, valid ten minutes, to open in a browser. The form shows the three fields of credentials.schema, and the password is typed on the platform's page only.

curl -X POST "http://localhost:3000/api/integrations/@acme/intranet/auths/primary/connect/session" \
  -H "Authorization: Bearer $APPSTRATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
# {"connect_url":"http://localhost:3000/api/integrations/connect/start?token=...","expiresAt":"..."}

A backend that already holds the credentials can create the connection directly:

curl -X POST "http://localhost:3000/api/integrations/@acme/intranet/auths/primary/connect/fields" \
  -H "Authorization: Bearer $APPSTRATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "credentials": { "zone": "north", "username": "alice", "password": "correct-horse-battery" } }'
# {"id":"ea386420-...","integration_package_id":"@acme/intranet","auth_key":"primary",
#  "account_id":"default","needs_reconnection":false,"label":"Connexion 1",...}

Either way, the platform sends the login request at once and stores the session cookie. The login answer carries no identity, so the connection gets a generic label and the account id default. Keep the connection id: you need it to reconnect later.

When the intranet refuses the login, the call answers 500 with the code internal_error, and the API log names the reason, for example LoginError: unexpected status 200.

Check the call path

Before involving a model, send one request through the credential proxy. It uses the same connection and the same authorized_uris check as a run, and injects the cookie on the platform:

curl "http://localhost:3000/api/credential-proxy/proxy" \
  -H "Authorization: Bearer $APPSTRATE_KEY" \
  -H "X-Integration-Id: @acme/intranet" \
  -H "X-Target: https://intranet.acme.test/reports/sales?from=2026-09-01&to=2026-09-30" \
  -H "X-Session-Id: 3b241101-e2bb-4255-8caf-4136c566a962"

The answer is the intranet's own, with a Proxy-Status: appstrate; received-status=200 header:

<html>
  <body>
    <div id="menu">Home | Reports | Logout</div>
    <h2>Sales 2026-09-01 to 2026-09-30</h2>
    <table id="sales">
      <thead>
        <tr>
          <th>Date</th>
          <th>Region</th>
          <th>Customer</th>
          <th>Amount</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>2026-09-02</td>
          <td>North</td>
          <td>Bolt & Co</td>
          <td class="num">1200.50</td>
        </tr>
        <tr>
          <td>2026-09-15</td>
          <td>South</td>
          <td>Maple Foods</td>
          <td class="num">845.00</td>
        </tr>
        <tr>
          <td>2026-09-28</td>
          <td>North</td>
          <td>Kite Labs</td>
          <td class="num">2310.00</td>
        </tr>
      </tbody>
    </table>
  </body>
</html>

A target outside authorized_uris is refused before it leaves the platform, with 403 and the code unauthorized_target. The proxy is described in Runs.

Teach the page layout with a skill

The integration says how to reach the app. A skill says how to read it, and any agent of the space can reuse it. Create a folder intranet-reports/ with a SKILL.md:

---
name: intranet-reports
description: How to read the Acme intranet sales report through the intranet integration's api_call tool. Use when a task needs sales figures from the intranet.
---

# Reading the Acme intranet

The intranet has no API. Its pages are HTML, fetched with the `api_call` tool of the
Acme intranet integration. The session cookie is added for you: never send a `Cookie`
header and never post to `/login`.

## Sales report

    GET https://intranet.acme.test/reports/sales?from=YYYY-MM-DD&to=YYYY-MM-DD

The answer is an HTML page. The data is the table with `id="sales"`: one `<tr>` per sale,
cells in this order: date, region, customer, amount (decimal point, no currency sign).
Ignore the menu and the headings. Decode HTML entities (`&amp;` is `&`).

## Expired session

If the page contains the login form (a `<form action="/login">` with a password field)
instead of the table, the session has expired. Do not retry and do not guess figures.
Return `needs_reconnect: true` with no rows: a person has to reconnect the integration.

## Large pages

When the result says it was written to a file under `resources/`, read that file with
your file tools, or parse it with a short `python3` script, instead of asking again.

And a manifest.json next to it:

{
  "name": "@acme/intranet-reports",
  "version": "1.0.0",
  "type": "skill",
  "schema_version": "0.3",
  "display_name": "Intranet reports",
  "description": "How to read reports from the Acme intranet HTML pages."
}

Import it the same way:

cd intranet-reports && zip ../intranet-reports.afps manifest.json SKILL.md && cd ..

curl -X POST "http://localhost:3000/api/packages/import" \
  -H "Authorization: Bearer $APPSTRATE_KEY" \
  -F file=@intranet-reports.afps
# {"packageId":"@acme/intranet-reports","type":"skill","version":"1.0.0"}

Create the agent

Create a folder sales-report/ with the manifest:

{
  "name": "@acme/sales-report",
  "version": "1.0.0",
  "type": "agent",
  "schema_version": "0.3",
  "display_name": "Sales report",
  "description": "Reads the sales table of the Acme intranet for a period and returns it as JSON.",
  "author": "Acme",
  "timeout": 300,
  "input": {
    "schema": {
      "type": "object",
      "properties": {
        "from": {
          "type": "string",
          "format": "date",
          "description": "First day, YYYY-MM-DD. Default: first day of last month"
        },
        "to": {
          "type": "string",
          "format": "date",
          "description": "Last day, YYYY-MM-DD. Default: last day of last month"
        }
      }
    }
  },
  "output": {
    "schema": {
      "type": "object",
      "properties": {
        "from": { "type": "string" },
        "to": { "type": "string" },
        "rows": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "date": { "type": "string" },
              "region": { "type": "string" },
              "customer": { "type": "string" },
              "amount": { "type": "number" }
            },
            "required": ["date", "region", "customer", "amount"]
          }
        },
        "total": { "type": "number" },
        "needs_reconnect": { "type": "boolean" }
      },
      "required": ["from", "to", "rows", "total", "needs_reconnect"]
    }
  },
  "dependencies": {
    "skills": { "@acme/intranet-reports": "^1.0.0" },
    "integrations": { "@acme/intranet": "^1.0.0" }
  },
  "integrations_configuration": {
    "@acme/intranet": { "tools": ["api_call"] }
  },
  "runtime_tools": ["output"]
}

And the prompt, prompt.md:

Fetch the sales report of the Acme intranet for the period given in the input
(`from` to `to`, both included), following the intranet-reports skill. When the
input gives no period, use the previous calendar month: run `date +%F` to learn
today's date.

Return every sale of the period and the total of the amounts with the output tool.
If the intranet session has expired, return `needs_reconnect: true` and stop.

The agent gets the integration's tool as acme_intranet__api_call, the skill under .pi/skills/, and the output tool for its result. Both inputs are optional, so a schedule can run it without any. Import it:

cd sales-report && zip ../sales-report.afps manifest.json prompt.md && cd ..

curl -X POST "http://localhost:3000/api/packages/import" \
  -H "Authorization: Bearer $APPSTRATE_KEY" \
  -F file=@sales-report.afps
# {"packageId":"@acme/sales-report","type":"agent","version":"1.0.0"}

GET /api/agents/@acme/sales-report/connection-readiness confirms that your connection will be used before you launch anything.

Run it

# Launch. Answers 201 with the run resource; keep its id.
curl -X POST "http://localhost:3000/api/agents/@acme/sales-report/run" \
  -H "Authorization: Bearer $APPSTRATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "input": { "from": "2026-09-01", "to": "2026-09-30" } }'

# Wait for it to finish (up to 55 seconds per call).
curl "http://localhost:3000/api/runs/$RUN_ID?wait=true" \
  -H "Authorization: Bearer $APPSTRATE_KEY"

The agent calls acme_intranet__api_call on the sales page, reads the table and returns it through output. The finished run carries it in result.output:

{
  "status": "success",
  "result": {
    "output": {
      "to": "2026-09-30",
      "from": "2026-09-01",
      "rows": [
        { "date": "2026-09-02", "amount": 1200.5, "region": "North", "customer": "Bolt & Co" },
        { "date": "2026-09-15", "amount": 845, "region": "South", "customer": "Maple Foods" },
        { "date": "2026-09-28", "amount": 2310, "region": "North", "customer": "Kite Labs" }
      ],
      "total": 4355.5,
      "needs_reconnect": false
    }
  }
}

Schedule it

Run it at 7am on the first day of every month. With no input, the agent takes the previous month:

curl -X POST "http://localhost:3000/api/agents/@acme/sales-report/schedules" \
  -H "Authorization: Bearer $APPSTRATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Monthly sales", "cron_expression": "0 7 1 * *", "timezone": "America/Toronto" }'
# {"id":"sched_...","name":"Monthly sales","enabled":true,"cron_expression":"0 7 1 * *",
#  "timezone":"America/Toronto","input":{},"next_run_at":"2026-11-01T12:00:00.000Z",...}

A scheduled run executes as the person who created the schedule, with their connection. See Scheduling.

When the session expires

The login runs only when someone connects. The platform keeps the cookie, not the password, so it never logs in again by itself. When the intranet drops the session, it redirects to /login. api_call follows that redirect, which stays on an authorized host, and returns the login form with a 200 status: nothing flags the connection as broken. This is why the skill tells the agent to recognise the form and return needs_reconnect: true instead of empty figures.

To get a fresh session, run the connection flow again on the same connection, with its id as connection_id. The hosted form takes it the same way (connect/session with { "connection_id": "..." }):

curl -X POST "http://localhost:3000/api/integrations/@acme/intranet/auths/primary/connect/fields" \
  -H "Authorization: Bearer $APPSTRATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "connection_id": "ea386420-...", "credentials": { "zone": "north", "username": "alice", "password": "correct-horse-battery" } }'

The connection keeps its id, so the schedule and any pin that names it keep working.

Limits of the declarative login

  • One request. connect.login sends a single request, with no cookie jar and no redirect following. A login that first reads a CSRF token from the form page, takes several steps, or needs JavaScript cannot be written this way.
  • No automatic re-login. The password is not stored, so a session that expires needs a person to reconnect.
  • Values are inserted as typed. {{password}} is not URL-encoded in the body, so a password containing &, =, +, % or a space breaks a form body. Type it URL-encoded in the connect form (& as %26).
  • One host per integration. The host is fixed in authorized_uris and in the login URL.
  • The model reads the HTML. That suits a table of a few hundred rows. A larger page is written to a file under resources/ in the agent's workspace instead of the model's context, as described in Tools, and the skill tells the agent to parse it with a script.

When the HTML or the login gets hard

The code route is a local MCP server: an mcp-server package plus an integration with source.kind: "local", exposing one clean tool such as get_sales_report(from, to) that logs in, walks the pages and returns JSON. Its auth would hand the server the user's fields through delivery.env, so it can log in on every call and never meets an expired session. Custom integrations walks through such a package. Check three constraints first:

  • The server runs in its own container, which needs a container-capable backend (see Tools).
  • Its traffic leaves through the sidecar. The proxy the server is given accepts only CONNECT tunnels, so a plain http:// request sent through it is refused: plan for HTTPS.
  • That route applies the private address blocklist without the EGRESS_ALLOW_INTERNAL_HOSTS exemption. An app on a private address is refused, so today this route fits an app published on a public HTTPS address, not one inside a private network.

For an app on a private network, keep the declarative login and api_call described on this page.

Troubleshooting

SymptomCauseFix
Import refused: delivery.http.in "cookie" is not yet supportedOnly header delivery is implementedSend the cookie as a Cookie header with a prefix, as in step 2
Connecting answers 500 internal_error, log LoginError: unexpected statusThe app did not answer as success_criteria expects: wrong credentials, an unencoded special character, or a success status other than the one declaredCheck the credentials, encode special characters, and match success_criteria to the app
Import or save refused, the message ending in the platform does not evaluate it (AFPS §7.6/§7.7)A connect.login criterion or output outside the forms the login engine evaluates: a compound condition, an unquoted string, xpath, a regex that does not compileWrite one <expression> == <value> comparison per criterion and quote strings ('ok'), or use a regex criterion
Log LoginError: url targets a blocked/internal addressThe intranet host is on a private address and not in EGRESS_ALLOW_INTERNAL_HOSTSStep 1
Log LoginError: output 'session_id' extracted an empty valueThe cookie named in outputs is not in the login answerCheck the cookie name the app sets
403 unauthorized_target on a callThe URL is outside authorized_urisFix the URL, or the pattern
The run succeeds with needs_reconnect: trueThe session expiredReconnect, as described above

Resources

On this page