Back to blog
Chatmaid Developers··11 min read

How to Add Conversation Memory to Your WhatsApp AI Agent

Give a WhatsApp AI agent memory: save each message turn in Google Sheets or Supabase, load the recent history before every AI call, and handle retries.

Chatmaid Team
How to Add Conversation Memory to Your WhatsApp AI Agent

To give a WhatsApp AI agent memory, save every message turn in a database keyed by the customer's phone number. When a new message arrives, load that customer's most recent turns and send them to the model together with the new message. With Chatmaid Developers, each incoming WhatsApp message reaches your server as a message.received webhook, and your agent replies through POST /v1/messages/send. Every webhook is a separate request, and a chat completion call only sees what you send in it, so the history has to live in a store you control.

Without memory, the agent treats every message as the first one. A customer who answered your qualification questions yesterday gets asked them again today. With memory, the agent knows their name, what they asked last time and what it already told them.

This guide, current as of October 2026, shows two ways to add that memory: Google Sheets for a small agent built in n8n, and Supabase (hosted Postgres) for an agent written in code.

Why a WhatsApp AI agent forgets

Each WhatsApp message reaches your agent as a separate webhook request, and every call to OpenAI's Chat Completions API or Claude's Messages API starts from zero: the model only has the context you put in that request. OpenAI's Responses API can keep a conversation on OpenAI's side (conversation state), but you would still need to map each phone number to its conversation, handle retries and expire old history yourself, so this guide keeps the history in your own store.

The pattern is always the same:

  1. A message arrives. Save it.
  2. Load the customer's recent turns from your store.
  3. Send them to the model, oldest first, with your system prompt in front.
  4. Save the model's reply, then send it to the customer.

What to store

For each turn, store:

  • Phone number: the customer's number, which is the from field of the message.received webhook
  • Role: user for the customer, assistant for your agent
  • Content: the message text
  • Timestamp: when the turn happened
  • Message ID (inbound turns only): the webhook's messageId, so a retried webhook isn't saved twice

If your server doesn't answer with a 2xx, Chatmaid makes up to 3 attempts to deliver each event, so the same message can arrive twice. Without a check, it ends up in the history twice and the agent answers it twice. The webhook guide covers retries and signatures in detail.

Approach 1: Google Sheets (simple, works well with n8n)

Google Sheets suits a low-volume agent built in n8n. It costs nothing, needs no database setup, and you can read every conversation in the sheet. This section assumes you already have the agent from the n8n customer service guide: a trigger node named Webhook, a signature check, an "Extract Message" node, an HTTP Request node named "AI Response" that calls OpenAI, and a "Send Reply" node. You'll add four nodes:

  1. "Get History" (Google Sheets) and "Build Messages" (Code), between Extract Message and AI Response (after the pause check, if you added it).
  2. "Save Turns" (Code) and a Google Sheets Append Row node, after Send Reply. If you added the guide's optional Log Conversation node, these two replace it.

Set up the sheet

Create a sheet with these four columns:

phonerolecontenttimestamp
+15557654321userHi, do you deliver on Saturdays?2026-10-19T10:00:00Z
+15557654321assistantYes, we deliver on Saturdays from 9 a.m. to 1 p.m.2026-10-19T10:00:02Z

Format the phone column as plain text (Format, then Number, then Plain text) before you add rows. Otherwise Google Sheets can read +15557654321 as a number and drop the plus sign, and the lookup below stops matching.

Read the history

Add a Google Sheets node before your AI call and name it "Get History":

  • Operation: Get Row(s)
  • Filters: Column phone, Value {{ $('Webhook').item.json.body.data.from }}
  • Options: add When Filter Has Multiple Matches and set it to Return All Matches. By default the node returns only the first matching row.
  • Settings tab: turn on Always Output Data. A first-time customer has no rows, and without this setting the workflow stops at this node.

The node has no limit or sort option (n8n's Google Sheets docs list what it supports). You append rows in time order, so the rows come back oldest first, and the next node keeps the last 20.

Build the messages array

Add a Code node in "Run Once for All Items" mode and name it "Build Messages". Move your system prompt out of AI Response and into SYSTEM_PROMPT here:

// Rows from "Get History", oldest first.
// The filter drops the empty item that "Always Output Data" adds for new customers.
const SYSTEM_PROMPT = '<paste the system prompt from AI Response here>';
const incoming = $('Webhook').first().json.body.data;

const history = $input.all()
  .filter((item) => item.json.role && item.json.content)
  .slice(-20) // the last 20 messages, about 10 exchanges
  .map((item) => ({
    role: item.json.role,
    content: String(item.json.content),
  }));

const messages = [
  { role: 'system', content: SYSTEM_PROMPT },
  ...history,
  {
    role: 'user',
    content: incoming.content || '[The customer sent a ' + incoming.type + ' message with no text]',
  },
];

// pairedItem links back to the incoming message, so later .item lookups keep working
return [{ json: { messages }, pairedItem: { item: 0 } }];

Keep the ESCALATE instruction from the n8n guide's prompt, or its Needs Human? node stops working. The fallback text covers photos and voice notes sent without a caption, which arrive with an empty content. The pairedItem line matters once a customer has two or more rows: without it, the $('Extract Message').item expressions in Send Reply can't trace back through this node and fail.

Then open AI Response and replace its JSON body so it sends the array you just built:

{
  "model": "gpt-6-luna",
  "reasoning_effort": "none",
  "max_completion_tokens": 300,
  "messages": {{ JSON.stringify($('Build Messages').item.json.messages) }}
}

As of October 2026, gpt-6-luna is the model OpenAI lists as the replacement for GPT-4o, which it retires on January 14, 2027 (OpenAI deprecations). With reasoning off, the token cap applies to the reply itself. The reply text still comes back at choices[0].message.content, so Send Reply needs no change.

Save both turns

After Send Reply, add a Code node named "Save Turns" in "Run Once for All Items" mode. It returns one item per row:

const incoming = $('Webhook').first().json.body.data;
// The OpenAI reply from the "AI Response" HTTP Request node.
// If you use n8n's OpenAI node instead, point this at the field it returns.
const aiReply = $('AI Response').first().json.choices[0].message.content;
const userText = incoming.content || '[The customer sent a ' + incoming.type + ' message with no text]';

return [
  { json: { phone: incoming.from, role: 'user', content: userText, timestamp: incoming.receivedAt } },
  { json: { phone: incoming.from, role: 'assistant', content: aiReply, timestamp: new Date().toISOString() } },
];

Follow it with a Google Sheets node set to Append Row and Map Automatically. The field names above match the column names, so both rows land in the right columns. Under Options > Cell Format, choose Let n8n format, so phone numbers keep their plus sign.

Keep these two nodes at the end of the workflow. n8n runs a node once for each item it receives, so a Send Reply placed after the two-row output would send the reply twice.

Limits of Google Sheets

  • API quotas. As of October 2026, the Google Sheets API allows 60 read requests and 60 write requests per minute per user per project (Google's usage limits). Every incoming message uses at least one of each, so a burst of messages can hit the cap.
  • No indexes. Lookups slow down as the sheet grows.
  • No expiry. Old conversations stay until you delete rows by hand.
  • No duplicate check. A retried webhook can append the same turn again. Retries are rare, because the n8n Webhook node answers right away, and Send Reply's idempotency key still stops a second WhatsApp reply.

When you outgrow these, move to a real database.

Approach 2: Supabase (for production)

Supabase is hosted Postgres with client libraries for Python and JavaScript. Its Free plan includes a 500 MB database, but it pauses a free project after a week of inactivity (Supabase pricing, October 2026), so an agent that sits idle for a week stops answering until you restore it. Use a paid plan for anything customers rely on.

Create the table

Run this in Supabase's SQL editor:

CREATE TABLE whatsapp_chat_history (
  id BIGSERIAL PRIMARY KEY,
  phone_number TEXT NOT NULL,
  message_id TEXT UNIQUE,
  role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
  content TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

ALTER TABLE whatsapp_chat_history ENABLE ROW LEVEL SECURITY;

-- Covers lookups by phone number, newest first
CREATE INDEX idx_whatsapp_phone_time
  ON whatsapp_chat_history (phone_number, created_at DESC);

Tables you create in the SQL editor don't get Row Level Security on their own, and Supabase's API serves tables without it to anyone holding your project's public key. With RLS on and no policies, only your server's secret key can read or write the table.

message_id is unique, so the database itself rejects a second copy of the same inbound message. Your agent's own replies leave it empty, and Postgres allows any number of empty values in a unique column.

Read the history

Save this as memory.py. Use a Supabase secret key (or the legacy service_role key) on your server. Those keys bypass Row Level Security, so they belong in backend code only, never in a browser or an app (Supabase API keys).

import os

from postgrest.exceptions import APIError
from supabase import create_client

supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_KEY"])
TABLE = "whatsapp_chat_history"


def get_conversation_history(phone: str, limit: int = 20) -> list[dict]:
    """Return the last `limit` turns for a phone number, oldest first."""
    result = (
        supabase.table(TABLE)
        .select("role, content")
        .eq("phone_number", phone)
        .order("created_at", desc=True)
        .limit(limit)
        .execute()
    )
    return list(reversed(result.data))

The query sorts newest first and then reverses the list. Sorting oldest first with a limit would return the first 20 messages the customer ever sent, not the latest 20.

The same query in Node.js:

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY);

export async function getConversationHistory(phone, limit = 20) {
  const { data, error } = await supabase
    .from('whatsapp_chat_history')
    .select('role, content')
    .eq('phone_number', phone)
    .order('created_at', { ascending: false })
    .limit(limit);

  if (error) throw error;
  return data.reverse(); // oldest first
}

Save each turn

Add this to memory.py:

def save_message(phone: str, role: str, content: str, message_id: str | None = None) -> bool:
    """Store one turn. Returns False if this inbound message is already stored."""
    try:
        supabase.table(TABLE).insert(
            {
                "phone_number": phone,
                "role": role,
                "content": content,
                "message_id": message_id,
            }
        ).execute()
    except APIError as err:
        if err.code == "23505":  # unique_violation: a retried webhook
            return False
        raise
    return True

The str | None hints need Python 3.10 or later.

Use it in your webhook handler

This Flask app answers one-to-one text messages. Save it as app.py. It checks the X-Chatmaid-Signature header, returns 200 right away and does the slow work (the AI call and the send) in a background thread. Chatmaid waits up to 30 seconds for your answer, and a slow model call shouldn't push a webhook into a retry.

import hashlib
import hmac
import json
import os
import threading

import requests
from flask import Flask, jsonify, request
from openai import OpenAI

from memory import get_conversation_history, save_message

app = Flask(__name__)
openai_client = OpenAI()  # reads OPENAI_API_KEY

CHATMAID_API_KEY = os.environ["CHATMAID_API_KEY"]  # sk_test_... or sk_live_...
WEBHOOK_SECRET = os.environ["CHATMAID_WEBHOOK_SECRET"]
FROM_PHONE = os.environ["CHATMAID_FROM_PHONE"]  # your connected number, e.g. +15551234567
MODEL = os.environ.get("OPENAI_MODEL", "gpt-6-luna")
SYSTEM_PROMPT = "You are the WhatsApp assistant for a small bakery. Keep answers short and friendly."

_locks: dict[str, threading.Lock] = {}
_locks_guard = threading.Lock()


def lock_for(phone: str) -> threading.Lock:
    """One lock per customer, so their messages are answered one at a time."""
    with _locks_guard:
        return _locks.setdefault(phone, threading.Lock())


def verify_signature(raw_body: str, signature_header: str, secret: str) -> bool:
    # Header format: t=1700000000,v1=<hex>
    parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
    payload = (parts.get("t", "") + "." + raw_body).encode("utf-8")
    expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts.get("v1", ""), expected)


def send_whatsapp(to: str, content: str, idempotency_key: str) -> None:
    response = requests.post(
        "https://developers-api.chatmaid.net/v1/messages/send",
        headers={"Authorization": f"Bearer {CHATMAID_API_KEY}"},
        json={
            "fromPhoneId": FROM_PHONE,
            "to": to,
            "content": content,
            "idempotencyKey": idempotency_key,
        },
        timeout=30,
    )
    response.raise_for_status()


def handle_message(data: dict) -> None:
    phone = data["from"]
    try:
        with lock_for(phone):
            # 1. Save the incoming message. False means a retry we already handled.
            if not save_message(phone, "user", data["content"], message_id=data["messageId"]):
                return

            # 2. Load the last 20 turns, oldest first. This includes the message just saved.
            history = get_conversation_history(phone, limit=20)

            # 3. Call the model with the history as context
            response = openai_client.chat.completions.create(
                model=MODEL,
                messages=[{"role": "system", "content": SYSTEM_PROMPT}, *history],
            )
            ai_reply = response.choices[0].message.content

            # 4. Save the reply, then send it from your WhatsApp number
            save_message(phone, "assistant", ai_reply)
            send_whatsapp(phone, ai_reply, idempotency_key="reply-" + data["messageId"])
    except Exception:
        app.logger.exception("Could not answer message %s", data.get("messageId"))


@app.post("/webhook/whatsapp")
def webhook():
    raw_body = request.get_data(as_text=True)
    signature = request.headers.get("X-Chatmaid-Signature", "")
    if not verify_signature(raw_body, signature, WEBHOOK_SECRET):
        return jsonify({"error": "invalid signature"}), 401

    event = json.loads(raw_body)
    kind = event.get("event")
    data = event.get("data") or {}
    # The dashboard's test event and status events don't carry these fields, so use .get()
    one_to_one_text = not data.get("isGroup") and data.get("type") == "text" and data.get("content")

    if kind == "message.received" and one_to_one_text:
        threading.Thread(target=handle_message, args=(data,)).start()
    elif kind == "message.outgoing" and one_to_one_text:
        # A person on your team replied from the phone. Remember it as an assistant turn.
        save_message(data["to"], "assistant", data["content"])

    return jsonify({"received": True}), 200


if __name__ == "__main__":
    app.run(port=3000)

Notes on the handler:

  • Two layers against duplicates. The unique message_id stops a retried webhook from being saved or answered twice. The idempotencyKey on the send means that even a repeated send call delivers only one reply. For a key it has already seen, Chatmaid returns the original message (or the original error, if that send failed at WhatsApp) instead of sending again.
  • Quick follow-up messages. Customers often split one question across two messages. The per-customer lock answers them one after the other, and the second answer sees the first exchange in its history. Errors in the background thread are logged instead of disappearing after the 200.
  • Replies typed on the phone. Tick Message Sent From Phone (message.outgoing) in the event list on the dashboard's Webhooks page. When someone on your team answers by hand, the agent then knows what they said. Chatmaid reports API sends as message.sent, so the agent's own replies aren't normally saved twice. As written, the agent keeps answering after a teammate steps in. To pause it, see Step 8 of the LangChain guide, which does it in Python with a paused_chats table and the same webhook.
  • Node.js. The @chatmaid/sdk package exports verifyWebhook({ body, signature, secret, toleranceSeconds: 15 * 60 }), which checks the signature and returns the parsed event. Pass toleranceSeconds: the default window is 5 minutes, and retries keep the timestamp of the first attempt. As of October 2026 the published SDK (version 0.3.0) doesn't recognize message.outgoing and throws unknown_event for it, so verify that event with your own HMAC check, like verify_signature above.
  • HTTPS. Chatmaid accepts only HTTPS webhook URLs, so put the app behind HTTPS before you register it. A threaded Flask server is fine for a start. Under heavier traffic, hand the work to a job queue instead of a thread.

Session management

Sometimes a returning customer should start fresh, for example when they write about a new problem days after the last one. A simple rule is to load only the messages from the last 24 hours:

from datetime import datetime, timedelta, timezone

from memory import TABLE, supabase


def get_active_session_history(phone: str, session_timeout_hours: int = 24, limit: int = 20) -> list[dict]:
    cutoff = datetime.now(timezone.utc) - timedelta(hours=session_timeout_hours)
    result = (
        supabase.table(TABLE)
        .select("role, content")
        .eq("phone_number", phone)
        .gte("created_at", cutoff.isoformat())
        .order("created_at", desc=True)
        .limit(limit)
        .execute()
    )
    return list(reversed(result.data))

Call it in handle_message instead of get_conversation_history. A message after a long gap then starts with an empty context. The customer profile in the next section carries over what the agent should still know.

Extracting a customer profile from memory

Once you have the history, ask the model to pull out a short customer profile, and keep it in its own table, one row per customer:

CREATE TABLE customer_profiles (
  phone_number TEXT PRIMARY KEY,
  profile JSONB NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

ALTER TABLE customer_profiles ENABLE ROW LEVEL SECURITY;

Save this as customer_profile.py:

import json
import os
from datetime import datetime, timezone

from openai import OpenAI

from memory import supabase

openai_client = OpenAI()
MODEL = os.environ.get("OPENAI_MODEL", "gpt-6-luna")

PROFILE_PROMPT = (
    "Extract key customer information from this conversation. "
    "Return JSON with: name, email, budget, timeline, main_interest, "
    "qualification_status (cold, warm or hot). "
    "Only include fields you're confident about."
)


def extract_customer_profile(history: list[dict]) -> dict:
    if len(history) < 2:
        return {}

    response = openai_client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": PROFILE_PROMPT},
            {"role": "user", "content": "Conversation: " + json.dumps(history)},
        ],
        response_format={"type": "json_object"},
    )
    return json.loads(response.choices[0].message.content)


def get_customer_profile(phone: str) -> dict:
    result = (
        supabase.table("customer_profiles")
        .select("profile")
        .eq("phone_number", phone)
        .limit(1)
        .execute()
    )
    return result.data[0]["profile"] if result.data else {}


def update_customer_profile(phone: str, history: list[dict]) -> None:
    new = extract_customer_profile(history)
    if not new:
        return  # never overwrite what you know with an empty result
    profile = {**get_customer_profile(phone), **new}  # keep facts from earlier sessions
    supabase.table("customer_profiles").upsert(
        {
            "phone_number": phone,
            "profile": profile,
            "updated_at": datetime.now(timezone.utc).isoformat(),
        }
    ).execute()

Then wire it into app.py: add the import at the top and replace handle_message with this version:

from customer_profile import get_customer_profile, update_customer_profile


def handle_message(data: dict) -> None:
    phone = data["from"]
    try:
        with lock_for(phone):
            if not save_message(phone, "user", data["content"], message_id=data["messageId"]):
                return
            history = get_conversation_history(phone, limit=20)

            # Add what you know about the customer to the system prompt
            profile = get_customer_profile(phone)
            system = SYSTEM_PROMPT
            if profile:
                system += "\nWhat we know about this customer: " + json.dumps(profile)
            response = openai_client.chat.completions.create(
                model=MODEL,
                messages=[{"role": "system", "content": system}, *history],
            )
            ai_reply = response.choices[0].message.content

            save_message(phone, "assistant", ai_reply)
            send_whatsapp(phone, ai_reply, idempotency_key="reply-" + data["messageId"])

            # Refresh the profile with this exchange
            update_customer_profile(phone, [*history, {"role": "assistant", "content": ai_reply}])
    except Exception:
        app.logger.exception("Could not answer message %s", data.get("messageId"))

Refreshing after every reply costs one extra model call per message. To save calls, run update_customer_profile from a nightly job over the day's active customers instead. Either way, the agent remembers the essentials after a session resets, and a person who takes over an escalated chat sees them at a glance.

Managing storage

Conversation history only grows. Two ways to keep it in check:

Option A: delete old messages. Remove everything older than 90 days:

DELETE FROM whatsapp_chat_history
WHERE created_at < NOW() - INTERVAL '90 days';

To run it every week, turn on Cron under Integrations in the Supabase dashboard (Supabase Cron runs on the pg_cron extension) and schedule the same statement:

-- Every Sunday at 03:00
SELECT cron.schedule(
  'purge-old-whatsapp-history',
  '0 3 * * 0',
  $$DELETE FROM whatsapp_chat_history WHERE created_at < NOW() - INTERVAL '90 days'$$
);

Option B: summarize. Before you delete old turns, ask the model to summarize them, such as "returning customer, orders a birthday cake most months, prefers delivery", and store that in the customer's profile under a summary key. It then reaches the model through the system prompt with the rest of the profile, so the agent keeps the context while the history table stays small.

Testing memory behavior

Inbound message.received webhooks only come from a live, connected number. Before you go live, you can still test the whole loop locally. Use a sandbox key (sk_test_) as CHATMAID_API_KEY and your sandbox phone's number as CHATMAID_FROM_PHONE. Sandbox sends are simulated, so no real WhatsApp message goes out. Then sign and post a fake webhook to your app. Save this as send_test_webhook.py:

import hashlib
import hmac
import json
import os
import sys
import time
import uuid
from datetime import datetime, timezone

import requests

secret = os.environ["CHATMAID_WEBHOOK_SECRET"]
text = sys.argv[1] if len(sys.argv) > 1 else "Hi, do you deliver on Saturdays?"
message_id = sys.argv[2] if len(sys.argv) > 2 else "inmsg_test_" + uuid.uuid4().hex[:12]
now = datetime.now(timezone.utc).isoformat()

body = json.dumps(
    {
        "event": "message.received",
        "timestamp": now,
        "data": {
            "messageId": message_id,
            "from": "+15557654321",
            "to": os.environ["CHATMAID_FROM_PHONE"],
            "content": text,
            "type": "text",
            "isGroup": False,
            "groupId": None,
            "receivedAt": now,
        },
    }
)

t = str(int(time.time()))
v1 = hmac.new(secret.encode("utf-8"), f"{t}.{body}".encode("utf-8"), hashlib.sha256).hexdigest()

response = requests.post(
    "http://localhost:3000/webhook/whatsapp",
    data=body,
    headers={
        "Content-Type": "application/json",
        "X-Chatmaid-Event": "message.received",
        "X-Chatmaid-Signature": f"t={t},v1={v1}",
    },
    timeout=10,
)
print(response.status_code, response.text)

Run it with a message as the argument, then check the table and the agent's replies for these cases:

  1. New contact: the first message from an unseen number. The agent greets the customer and starts from scratch.
  2. Returning contact: a second message from the same number. The reply refers to the earlier exchange.
  3. Session gap: move the history back in time with UPDATE whatsapp_chat_history SET created_at = created_at - INTERVAL '48 hours' WHERE phone_number = '+15557654321'; and send again. With session history on, the agent starts fresh. If you added the customer profile to the system prompt, it still knows the customer's details.
  4. Long conversation: after 30 or more messages, the model gets only the latest 20.
  5. Retry: run python send_test_webhook.py "Hi" inmsg_test_retry1 twice. The second run saves nothing and sends no reply.

Frequently asked questions

Can I use n8n's built-in memory instead of a sheet?

Yes, if you build the agent with n8n's AI Agent node. Simple Memory, with the customer's phone number as the session key, is fine for testing, but it lives inside n8n and n8n warns against it in queue mode. Postgres Chat Memory works with a Supabase database. Both store only what the AI Agent node sees, so replies typed on the phone and customer profiles still need your own table.

Doesn't Chatmaid already store the messages?

Chatmaid keeps a log of inbound messages (GET /v1/messages/inbound), but you can only filter it by your receiving number, not by customer, and your agent's replies are in a separate list. It isn't built to be read as one customer's conversation, so keep your own history table.

How many past messages should I send to the model?

The last 20 messages (about 10 exchanges) is a sensible starting point. More context costs more per call and can slow replies. For long relationships, summarize older turns and keep a customer profile instead of sending everything.

Can I test conversation memory without a live WhatsApp number?

Partly. Sandbox keys (sk_test_) are free and simulate sends, but inbound webhooks only come from a live, connected number. Test locally with a signed fake webhook as shown above. Real customer messages need a live number, which comes with the $7.99/month plan on Chatmaid Developers.

Does this work for WhatsApp groups?

The handler above skips group messages. To support groups, key the history by the webhook's groupId instead of from, add the sender's number to each saved turn so the model knows who said what, and send the reply with groupId as to.

What happens when someone on my team replies from the phone?

If you subscribe to message.outgoing, Chatmaid sends a webhook for messages typed on the connected phone, and the handler saves them as assistant turns. The agent then knows what your teammate said. Messages sent through the API are reported as message.sent instead.