Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~27 min· topic 5 of 9

Topic 5.5

Customising the Request

In one line

By default useChat posts the id and all the messages to /api/chat. DefaultChatTransport lets you change the URL, add headers (for a login token), add body fields (the product page, the language), set the cookie policy, or take over the whole payload and send only the newest message. The route must still treat everything in it as untrusted.

Think of it like this

Posting a parcel. The box is the messages. You can write extra labels on the outside (headers: who is sending it), tuck a note inside (body: which order it concerns), and choose whether the courier may use your membership card (credentials: cookies). The transport is your post counter: it decides what goes on the label and what goes in the box.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

DefaultChatTransport
The object useChat uses to POST the chat to your route and read the UI message stream back.
Resolvable
An option that can be a value or a function returning the value. The function runs on every request.
Bearer token
A login token sent as the header Authorization: Bearer <token>.
credentials
A fetch setting for cookies: omit, same-origin (default) or include.
prepareSendMessagesRequest
A function that returns the exact body, headers and URL of the chat request.
trigger
Why the request is sent: submit-message for a send, regenerate-message for a regenerate.
Allow-list
A fixed list of accepted values. Anything not on it is replaced or refused.
CORS
The browser rule about which other origins may call your route. Needed when credentials is include to another site.

Step by step

01Where each option goes

The first live example sets a token header as a function, a body field as a function, and credentials. It then sends three messages. The server prints what it received: the token changes per request because the function runs each time, customerId comes from the transport, and lang appears only on the message that had a per-request body.

transport optionswhole filets
new DefaultChatTransport({
  api: "/api/chat",
  headers: async () => ({ Authorization: "Bearer " + (await getToken()) }), // runs on every request
  body: () => ({ page: location.pathname }),                                 // merged into the JSON
  credentials: "include",                                                    // send cookies to another origin
});

sendMessage({ text }, { body: { lang: "hi" } });                             // one request only

02One message per request

The second example makes the browser send only the last message and the chat id. The server keeps the history in a map (a database in a real app), appends the new message, answers, and stores the result in onEnd. The log shows the history growing on the server while each request carries a single message.

Regenerate needs the trigger field: the server removes its last assistant message instead of adding a user message.

03An expired token, once

The third example starts with a stale token. The first send fails with 401; the script refreshes the token, clears the error and regenerates. The second try works. It tries exactly once more, never in a loop.

04Never trust the body

The fourth example sends model: "most-expensive-model" and userId: "admin" in the body, as a hostile page could. The server reads the identity from its token table and picks the model from an allow-list, and prints what it actually used. Defensive only: the client's claims are ignored, not obeyed.

Try it yourself

  1. 1

    Make the token a plain string

    In the first example replace the headers function with headers: { Authorization: "Bearer token-1" }.

    Now every request sends token-1, forever. A real token would expire and every request after that would get a 401 until the page is reloaded. Use the function form for anything that changes.

  2. 2

    Count the bytes

    In the second example change the route to console.log("(server) request body:", JSON.stringify({ id, trigger, message }).length, "characters") and send five messages.

    The size stays about the same for every request. With the default transport the body would grow by a whole turn each time, which matters on long chats and on platforms that cap the size of a request.

  3. 3

    Retry in a loop, safely

    In the third example make the refresh function do nothing (delete token = "fresh-token";) and run it.

    The second try also fails with 401 and the example stops, because it retries once. A loop that retried until success would hammer your login service forever with a wrong credential.

Code & diagrams

Headers, body and per-request options

The token is different each time because the function runs per request. 'lang' appears only on the second request. Every body also carries id, messages and trigger.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

(server) Bearer token-1 | body fields: customerId, messages, trigger | customerId: C-77 | lang: undefined
(server) Bearer token-2 | body fields: customerId, lang, messages, trigger | customerId: C-77 | lang: hi
(server) Bearer token-3 | body fields: customerId, messages, trigger | customerId: C-77 | lang: undefined
messages: 6
Send only the newest message, keep history on the server

Each request carried one message, yet the model saw the whole history. The last line is the cost of this design: when the server loses the history, the answer is given without any context, and the browser and the server no longer agree. Real apps store chats in a database.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

(server) submit-message | history: 1 messages
(server) submit-message | history: 3 messages
(server) regenerate-message | history: 3 messages
browser has 4 messages | server stored 4
(server) submit-message | history: 1 messages
after the server lost its history: server stored 2 | browser has 6
An expired token: refresh once, then retry

Retry once and stop. If the second try also fails with 401, show 'Please sign in again' instead of looping.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

first try: error | status code: 401
second try: ready | assistant | Your order ships tomorrow.
What the route ignores from the body

The route trusted the token and its own list, and ignored the client's claims. A real route also rate-limits per user id (Topic 3.6).

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

(server) client claims userId: undefined -> using u-42 | client asked for model: "small" -> using small-model
(server) client claims userId: "admin" -> using u-42 | client asked for model: "most-expensive-model" -> using small-model
last status: error | status code: 401

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Let the body choose the model

In the last example change const model = MODELS[body.model] || DEFAULT_MODEL; to const model = body.model || DEFAULT_MODEL;.

terminal
$ The console, second line
── what you'll see ──
(server) client claims userId: "admin" -> using u-42 | client asked for model: "most-expensive-model" -> using most-expensive-model

Myth vs fact

Myth

Sending the user id in the body is fine because my page fills it in.

Fact

Anyone can send a request without your page. Identity must come from a verified token or session on the server.

Myth

credentials: "include" is needed for cookies on my own site.

Fact

Same-origin requests already send cookies. include is for calling another origin, and it requires matching CORS headers on the server.

Myth

Sending only the last message is always better.

Fact

It shrinks the payload but moves the history to your server. You then own storage, consistency between tabs, and what happens when the stored history and the screen disagree.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    fetch on the transport is a handy place for logging, a timeout (AbortSignal.timeout), or a test double. Wrap the global fetch once and every chat request goes through it.

  • ▸

    Give your route a body schema (with zod) and reject unknown shapes with a 400. It documents the contract between the client and the server and catches version mismatches after a deploy.

  • ▸

    A request id header (x-request-id) that you generate in headers and log on both sides turns a user report ('it failed at 10:42') into a single log search.

  • ▸

    On a deploy, old pages may still send the old body shape for hours. Keep the route accepting both for a while, or return a clear 4xx that the UI turns into 'Please refresh the page'.

Remember this

  1. 1

    The problem this solves: A real route needs more than the messages. It must know who is asking (to check they are signed in and to rate-limit them), which chat this is, maybe the language or the product page. And the default payload gets bigger with every turn, because it contains the whole conversation. This topic shows each knob and where each one stops helping.

  2. 2

    The transport. useChat({ transport: new DefaultChatTransport({ ... }) }). Its options: api (the URL, default /api/chat), headers, body, credentials, fetch (your own fetch function, for tests or logging) and prepareSendMessagesRequest. headers, body and credentials can each be a plain value or a function (also an async one) that is called for every request. Use the function form for anything that changes, such as a token.

  3. 3

    Headers for sign-in. Send the user's token as Authorization: Bearer <token>. Make it a function so an expired token is replaced before the next request: headers: async () => ({ Authorization: "Bearer " + (await getToken()) }). If your site and route share an origin and use cookies, the browser sends them already (credentials defaults to same-origin). For another origin use credentials: "include", and then the server must allow it with CORS (Topic 1.5). The API key of the model provider is never a header the browser sends.

  4. 4

    Body fields. Whatever you put in body is merged next to id, messages, trigger and messageId in the JSON. You can set it once on the transport (body: () => ({ page: location.pathname })) or for one call: sendMessage({ text }, { body: { lang: "hi" }, headers: { ... } }). Per-request options are for things that belong to one message, such as 'answer in Hindi this time'.

  5. 5

    Sending only the newest message. With prepareSendMessagesRequest you build the whole request yourself. A common use is ({ id, messages, trigger, messageId }) => ({ body: { id, trigger, messageId, message: messages.at(-1) } }): the browser sends one message and the chat id, and the server keeps the history. The payload stays small (Topic 4.3), the server is the source of truth, and nobody can rewrite old turns. The cost: the server must store every chat (Topic 5.8), and a regenerate request has to say so (trigger: "regenerate-message") so the server drops its last assistant message.

  6. 6

    The route trusts none of it. A request is only text, and anyone can send it, with or without your page. Take the user's identity from the verified token, never from a userId in the body. Take the model name from an allow-list on the server, never straight from the body (a client asking for the most expensive model is a cost attack). Limit the body size, and the number and length of messages. These are the rules of Topic 1.5 applied to the SDK's payload.

  7. 7

    Failures. A missing or expired token gives a 401 from your route. useChat moves to error, and error.statusCode is 401 with the response body as error.message. The good flow is: refresh the token once, clearError(), regenerate() or send again, and give up after one retry so a bad credential cannot loop. A request that is too large gives a 413 from the platform, with a body that is not your JSON, which is another reason to map errors by statusCode and show your own text (Topic 5.6).

Explain it without notes

01

How do you attach a login token to every chat request, and why should it be a function?

02

When would you use prepareSendMessagesRequest, and what does the server then have to do?

03

Which values in a chat request body must the server never trust, and what does it use instead?

04

A request returns 401. What should the UI do, and what stops it looping?

Practice

01

Add a header x-request-id with a fresh value on every request using the transport's headers, and make the route echo it in its log line.

02

Make the route in the fourth example return a 413 with { error: "Message too long." } when the last user message has more than 500 characters. How does the client see it?

Trade-offs

  • ↔

    Headers and body fields make the route flexible, but every extra field is another thing the server must validate and every old client may not send. Add only what the route really needs.

  • ↔

    Sending only the last message makes requests small and the server authoritative, at the price of storage, migrations and multi-tab consistency work. Sending everything is simpler and stateless; it costs bandwidth and tokens on every turn.

Done when you can

  • I can set the URL, headers, body and credentials on the transport, as values and as functions.

  • I can pass a field for a single request with the second argument of sendMessage.

  • I can send only the newest message with prepareSendMessagesRequest and handle regenerate.

  • I can explain which request fields the route must ignore, and where the real values come from.

  • I can handle a 401 with one refresh and one retry.