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:
- Write an integration that logs in with the user's own account when they connect, and keeps only the session cookie.
- Check the call path with one request through the platform's credential proxy.
- Teach the agent the layout of the page with a skill.
- Build an agent that returns the sales of a period as JSON.
- 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 expiredHow 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 thecredential-proxy:callscope, which a key created withoutscopesalready carries when its creator holds it (see API keys). - A default model for the organization, as in Model providers.
zipandcurl. The examples usehttp://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.testThe 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.schemais the form the user fills in when connecting.connect.login.requestis the one request the platform sends. Each{{name}}is replaced by the field of the same name that the user typed.success_criteriasays what success looks like. The platform does not follow redirects on this request, and without criteria only a2xxcounts as success, so declare the302the app sends. A wrong password returns200with 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.outputsnames what to keep from the answer: here the value of theSESSIONIDcookie inSet-Cookie. Only the outputs are stored, and each becomes a credential field,{$credential.session_id}.delivery.httpsays how the sidecar sends it: aCookieheader.in: "header"is the only placement implemented today, and the import refusesin: "cookie".authorized_urislists the only URLs that may receive the cookie and the login request. It must be literal: an auth that declaresconnectcannot 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 itsapi_calltool.
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 (`&` 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.loginsends 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_urisand 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
CONNECTtunnels, so a plainhttp://request sent through it is refused: plan for HTTPS. - That route applies the private address blocklist without the
EGRESS_ALLOW_INTERNAL_HOSTSexemption. 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
| Symptom | Cause | Fix |
|---|---|---|
Import refused: delivery.http.in "cookie" is not yet supported | Only header delivery is implemented | Send the cookie as a Cookie header with a prefix, as in step 2 |
Connecting answers 500 internal_error, log LoginError: unexpected status | The app did not answer as success_criteria expects: wrong credentials, an unencoded special character, or a success status other than the one declared | Check 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 compile | Write one <expression> == <value> comparison per criterion and quote strings ('ok'), or use a regex criterion |
Log LoginError: url targets a blocked/internal address | The intranet host is on a private address and not in EGRESS_ALLOW_INTERNAL_HOSTS | Step 1 |
Log LoginError: output 'session_id' extracted an empty value | The cookie named in outputs is not in the login answer | Check the cookie name the app sets |
403 unauthorized_target on a call | The URL is outside authorized_uris | Fix the URL, or the pattern |
The run succeeds with needs_reconnect: true | The session expired | Reconnect, as described above |