# Worked example: a support page

> A backend you host lets signed-in support staff find a customer and see what Anectico observed of them, with unresolved identities, withheld fields and unknown activity shown as they are.

Canonical page: https://anectico.com/docs/apps/support-page/


A page for your support team: type a customer's stable ID or a readable profile value, see which people in Anectico match, open
one and see their activity counts, with a link to Anectico's proof page for staff who have their own
Anectico access. Support staff sign in to this page, never to Anectico.

Email properties are redacted, so an email lookup works only when the application used that email
as its identifier. Prefer stable application IDs; see [redaction](/docs/reference/redaction) and
[customer identity](/docs/instrument/identity#choose-a-stable-id).

The runnable example is `scripts/testing/examples/apps/support_page.py` in the Anectico repository,
with the shared client `anectico_rest.py`. Python 3.11 or later, standard library only.

## Before you start

| Decision | This example |
| --- | --- |
| Where it runs | Your support tool's backend. |
| Project | Exactly one, named in every read. |
| Runtime permission | `persons:read` only. Add `persons:profile:read` only if the page must show profile properties, and then show only the fields you list in `SUPPORT_FIELDS`. Session replay and conversation content need their own content permissions and are not part of this example. |
| Builder authority | Your agent's own credential provisions the key; the page never holds it. |
| Secret | `ANECTICO_API_KEY` from the host's secret store. Staff passwords (`SUPPORT_STAFF`) are the page's own. |
| Expiry and stop | A fixed expiry; `anectico apikey revoke <key-id>`. |
| Contract | `contract-pin.json`; the page refuses to start against another contract. |

## What it calls

| Operation | Qualification | What the restriction means for this page |
| --- | --- | --- |
| `GET /api/v1/persons` | Restricted | The activity summary of each person (`impact`) can be missing from an answer without an error. The page shows "unavailable" whenever it is missing and never presents a missing summary as no activity, and it does not claim to be a complete health view of a customer. |
| `GET /api/v1/persons/{person_id}/stats` | Restricted | The counts cover whatever history Anectico still keeps, and the answer does not say how far back that is. The page says so next to the counts. |

The page reads one page of at most ten matches and never follows the cursor: support staff narrow the
search instead. A support page is a lookup, not a list of all your customers.

## Search

<!-- example: support_page.py#search -->
```python
def search(self, text: str) -> dict[str, Any]:
    """One page of people whose identities or properties contain the text."""
    answer = self._client.get("/api/v1/persons", {
        "project_id": self._config.project_id,
        "search": text,
        "limit": str(PAGE_SIZE),
    }, deadline=10).body
    people = []
    for person in answer["persons"]:
        impact = person.get("impact")
        people.append({
            "id": person["id"],
            "distinct_ids": list(person["distinct_ids"]),
            "identified": bool(person["is_identified"]),
            # Missing impact means it could not be measured, never zero activity.
            "activity": None if impact is None else {
                "events": impact["event_count"], "errors": impact["error_count"],
                "traces": impact["trace_count"], "last_seen": impact.get("last_seen"),
            },
            "withheld": list(person.get("content_withheld") or []),
            "withheld_reason": person.get("content_withheld_reason") or "",
            # Only the fields this page is configured to show leave the backend.
            "fields": {k: v for k, v in (person.get("properties") or {}).items() if k in self._config.fields},
        })
    return {"people": people, "more": bool((answer.get("pagination") or {}).get("has_more"))}
```

What the page shows, and why:

- **Several matches stay several people.** When two people match, the page lists both and says they
  are separate people in Anectico. It never merges them, picks one, or invents a canonical customer.
- **Anonymous stays anonymous.** A person whose `is_identified` is `false` is labeled anonymous.
- **Withheld is shown as withheld.** Without `persons:profile:read`, profile properties are withheld and
  the answer says so in `content_withheld` and `content_withheld_reason`; the page prints "Withheld by
  your privacy settings: person_properties (scope)" instead of an empty profile.
- **Unknown is not zero.** A missing activity summary reads "Activity summary: unavailable".
- **Only listed fields leave the backend.** Even with profile access, the page returns only the property
  names in `SUPPORT_FIELDS`.

## Open one person

<!-- example: support_page.py#person -->
```python
def person(self, text: str, person_id: str) -> dict[str, Any]:
    """One person, only if the same search still returns them: the page never opens an arbitrary ID."""
    found = next((p for p in self.search(text)["people"] if p["id"] == person_id), None)
    if found is None:
        raise NotFound("that person is not among the search results", status=404)
    path = Client.path("/api/v1/persons/{person_id}/stats", person_id=person_id)
    stats = self._client.get(path, {"project_id": self._config.project_id}, deadline=10).body.get("stats")
    link = None
    if self._config.console_url:
        link = proof_link(self._manifest, self._config.console_url, "person", self._config.project_id, id=person_id)
    return {"person": found, "stats": stats, "link": link}
```

The page opens a person only if the same search returns them, so a crafted address cannot read an
arbitrary ID. The counts are 64-bit decimal strings and are shown exactly.

The "Open in Anectico" link is built from the proof pages the deployment's contract declares (the
`proof_links` of the contract manifest), never from a hand-written address. It opens only for someone
signed in to Anectico with access to the project; it grants nothing to anyone else, and the page never
embeds it in a frame.

This example does not show slowest traces, a timeline or a churn prediction: a global "slowest" order
and a complete timeline are not part of what these operations promise, and Anectico makes no
predictions about your customers.

## The page's own API

`/?q=<text>` searches (3 to 200 characters) and `/person?q=<text>&id=<person id>` opens a person. Any
other parameter, a repeated parameter, or any other path is refused. Every value is HTML-escaped, the
page carries no script, and request logs record the path only, because a query can contain a
customer's email address.

| Anectico answered | The page shows |
| --- | --- |
| Busy or unavailable after the retries | `503` "Anectico is busy. Try again shortly." Never "No person matched". |
| A paused limit | `503` "Reading is paused by a limit an owner set." |
| `401` or `403` | `503` "This page's access to Anectico has ended." |
| `404` | "Not found." |

## Run it

```bash
export ANECTICO_API_URL=https://app.anectico.com ANECTICO_CONSOLE_URL=https://app.anectico.com
export ANECTICO_PROJECT_ID=<project-uuid> ANECTICO_API_KEY="$(your-secret-store read anectico/support-page)"
export SUPPORT_STAFF='dana:a-long-staff-password' SUPPORT_FIELDS=plan
python3 support_page.py
```

## Stop it and clean up

Revoke the page's key. The page does not store what it shows, but browsers and any logs or tickets your
staff copy it into are outside Anectico: when you erase a person in Anectico, also delete what your own
systems kept about them. See [Erase a person](/docs/manage/erase-a-person).

## Related

- [Polling, retries and refusals](/docs/apps/polling-and-streaming)
- [Proof pages](/docs/agents/proof-pages)
- [Permission scopes](/docs/reference/permissions)
