> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentcard.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sending each link: approval, Vault and store sign-in

> Deliver the approval, Vault, and store sign-in links to the shopper while your model never reads or writes one.

A purchase needs a link at three moments. The shopper approves the purchase on an approval page, a new shopper adds a card on a Vault page, and a shopper signs in to a store's own account on its login page before Agentcard can buy there. Your app delivers each link as one clear call to action, and the shopper finishes the step outside the chat.

Your model never touches a link. A tool runner puts each tool answer straight into the model's context, and a model that holds a link writes it into the chat. So the tool hands each link to your code, and the model reads only `link_sent: true`.

## Deliver each link

Pass `sendLink` to `buy.turn`. The tool hands it each link and waits for it, so the shopper holds the link before the model answers.

```ts chat.ts theme={null}
const turn = buyFor(shopper.id).turn(message, {
  sendLink: async (link) => {
    switch (link.kind) {
      case 'vault':
        return sendStep(shopper.id, 'Add a card', link); // your code
      case 'approval':
        return sendStep(shopper.id, 'Approve the payment', link);
      case 'store_login':
        return sendStep(shopper.id, `Sign in to ${link.store ?? 'the store'}`, link);
      default:
        return sendCard(shopper.id, link); // product and store cards
    }
  },
});
```

Each link's `kind` says which step it is:

| `kind` | The shopper does | Fields |
| - | - | - |
| `vault` | Connects and adds a card. | `url`, `expiresAt`, `id` |
| `approval` | Approves this purchase with a passkey. | `url`, `expiresAt`, `id`, and `store` when the answer names the cart's store |
| `store_login` | Signs in to the store named in `store`. | `url`, `expiresAt`, `id`, `store` |

`expiresAt` is an ISO 8601 timestamp, or null when Agentcard did not say. Send each `url` exactly as the link carries it, alone in its own message or behind one button. A Vault or approval link with anything appended to it fails verification and opens the Vault's sign-in page instead of the step.

### Keep the link out of the model's words

The model's description tells it never to write a link and to name each link by what it is for: the link to add a card, to sign in to the store, or to approve the payment. When the shopper asks for a link again, the model calls the tool, and the tool sends it again.

### Send a link in a text chat

In SMS or iMessage, send the `url` alone in its own message after the sentence that asks for it. iMessage shows a URL that stands alone in a message as a preview card, and it mangles one with words attached.

With `onItem` on the turn, the turn holds each step link until the message that asks for it went out, then sends the link right after it. The shopper reads "add your card to place the order" and then taps. A held link also goes out when the turn's next call starts, or after 20 seconds, so a model that writes nothing still sends it. "Answering each shopper message in its own turn" shows `onItem`.

### Read the links without `sendLink`

Without `sendLink`, `result.links` still carries each Vault, approval, and store sign-in link for your code, and the model still never reads one. Read them from `turn.links` or from the `onResult` callback.

## Open each step in a pop-up

Each step link carries a stable `id`, such as `vault_1x9k2qd`. It is the same every time the same link is handed over, on your server and in the browser, and it has no part of the URL in it, so it is safe as a DOM key or in a log. `actionLinkId(link)` reads the id from a link that has none.

`actionWindows` from `@agent-cards/commerce-sdk/browser` opens a step in a pop-up window centered over the chat, so the shopper never leaves it, and closes the pop-up the moment the message that ends the step arrives. The browser entry imports no Node module, so any bundler takes it.

```ts chat-ui.ts theme={null}
import { actionWindows } from '@agent-cards/commerce-sdk/browser';

const windows = actionWindows({
  onEnded: (step, outcome) => markStepDone(step.id, outcome), // your code
  onClosed: (step) => keepOfferingStep(step.id), // your code
});

// The link's own click handler, so the browser counts the tap as the reason for the pop-up.
// When the browser blocks it, `open` returns null and the link opens as a new tab.
stepLink.addEventListener('click', (event) => {
  if (windows.open(link)) event.preventDefault();
});

// Each shopper message your server forwards from `listen` or the webhook handler.
onShopperMessage((message) => windows.settle(message)); // your code
```

`settle` closes each open pop-up whose step the message ends and returns `[{ step, outcome }]`. A pop-up the shopper closes before the step ends reaches `onClosed`, checked every 500 milliseconds. The step is still open, so keep its link on screen, and a tap opens it again. A tap on a step whose pop-up is open brings that window to the front instead of opening a second one.

The pop-up is 480 by 760 pixels by default, kept inside the screen. Pass `width` and `height` to change it. A phone's browser opens the pop-up as a tab, and `settle` closes that tab the same way. `dispose()` stops watching and leaves every pop-up open, so a chat that remounts never shuts the page where a shopper is typing a card.

### Know which message ends which step

Each step ends with a message from Agentcard. `actionLinkOutcome(link, message)` returns how a message ended the step, or null when it left the step open. `ACTION_LINK_ENDINGS` holds the whole table:

| Link | `completed` | `declined` | `failed` | `expired` |
| - | - | - | - | - |
| `vault` | `card_added` | | | `card_link_expired` |
| `store_login` | `store_connected` | | `store_connect_failed` | `store_connect_expired` |
| `approval` | `order_placed`, `order_not_placed_yet`, `order_unconfirmed`, `order_confirmed`, `order_failed`, `payment_declined` | `approval_declined` | | `approval_expired` |

A reminder, such as `card_link_unfinished` or `approval_expiring`, leaves the step open. An approval is `completed` once the shopper approved, even when the order then failed or the payment was refused, because the approval page has nothing more to do. The message tells the shopper what happened.

A message whose facts name another link of the same kind (`facts.approvalUrl` or `facts.cardLinkUrl`) leaves this step open. So does a message about another store: DoorDash's `store_connected` never ends a Locale sign-in the shopper has open beside it.

## Handle a failed send

A `sendLink` that throws on a Vault or approval link makes the call throw, and the model never reads that a link went out. Catch the error, tell the shopper something went wrong, and let them ask again. A link your `sendLink` throws on stays out of `turn.links`.

## Check it in the sandbox

1. Send "two packs of AA batteries from Amazon" as a test shopper who has no card. Check that your app receives a `vault` link and that the model's reply says a link is waiting, without containing it.
2. Ask again. Check that you receive the same link, because Agentcard returns the link it already opened while it has time left.
3. Open the link and add a card with the code `111111`. Check that the pop-up closes when `card_added` arrives.
4. Close the pop-up before you finish. Check that `onClosed` fires and the link stays on screen.
5. Search the model's replies for `https://`. Check that none is there.

The sandbox stops before payment, so a confirm in the sandbox ends declined and places no order. "Approving a purchase" shows how to check the approval link with live credentials.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.