Skip to content

Cards, Actions, Forms, Dialogs, and App Home

Build a card and route a button

from chattice import Router
from chattice.cards import Button, ButtonList, Card, Section, TextParagraph
from chattice.events import ActionEvent

card = Card(
    sections=[
        Section(
            widgets=[
                TextParagraph("Deploy production?"),
                ButtonList(
                    buttons=[
                        Button(
                            "Deploy",
                            action="deploy.confirm",
                            parameters={"env": "prod"},
                        )
                    ]
                ),
            ]
        )
    ]
)

router = Router()


@router.action("deploy.confirm")
async def confirm(event: ActionEvent) -> str:
    return f"Deploying {event.parameters['env']}"

Button maps to a Google Cards v2 button; its function and string parameters become a normalized ActionEvent. For structured parameters, subclass ActionData and register YourData.filter() instead of decoding dictionaries throughout the application.

@router.action("deploy.confirm") is shorthand for @router.action(F.name == "deploy.confirm"). The action name is the value carried by the button's action=. Button parameters are exposed as event.parameters; they are separate from form values in event.form_inputs:

from chattice import F
from chattice.cards import Button, TextInput

TextInput(name="email", label="Email")
Button(
    "Continue",
    action="deploy.confirm",
    parameters={"email": "person@example.com", "group": "kk"},
)

The email entered into the TextInput is read from event.form_inputs["email"]. The explicit button values are read from event.parameters["email"] and event.parameters["group"].

Returning a Card from an action handler sends a new message for ordinary actions. In the pull Pub/Sub transport, when the action came from a known bot message, the same returned Card updates that message. Returning a str sends a text message. The handler may construct the Card inline or assign it to a local variable first; both forms have identical behavior.

Hosted images

Image renders an HTTPS-hosted picture inside a card:

from chattice.cards import Card, Image, Section

card = Card(
    sections=[
        Section(
            widgets=[
                Image(
                    image_url="https://example.com/result.png",
                    alt_text="Result",
                )
            ]
        )
    ]
)

Card Images are URL-only: image_url must be an absolute HTTPS URL — local paths, bytes and data: URLs are rejected at construction. on_click reuses the existing Action / OpenLink facades. A local file is not a Card Image — it is an attachment, see Files, Images & Media.

Forms and typed models

Form widgets include TextInput, SelectionInput, and DateTimePicker. Validation configures supported client-side limits/types. Submitted common.formInputs is already normalized into StringInput, DateInput, DateTimeInput, TimeInput, or UnknownFormInput.

from dataclasses import dataclass

from chattice.cards import ActionStatus
from chattice.events import ActionEvent, StringInput
from chattice.forms import FormModel


@dataclass
class ContactForm(FormModel):
    email: StringInput


@router.dialog_submit(ContactForm.filter())
async def save_contact(event: ActionEvent, form: ContactForm) -> ActionStatus:
    if not form.email.values:
        return ActionStatus.invalid("Email is required")
    return ActionStatus.ok(f"Saved {form.email.values[0]}")

Forms collect one interaction's data. FSM stores workflow state that must survive messages, callbacks, time, users, or restarts. A form is not an FSM.

Open a dialog

The button must declare OPEN_DIALOG, and the eligible REQUEST_DIALOG action returns a Dialog synchronously:

from chattice.cards import ButtonInteraction, Dialog, TextInput

open_button = Button(
    "Contact",
    action="contact.open",
    interaction=ButtonInteraction.OPEN_DIALOG,
)


@router.action("contact.open")
async def open_contact(event: ActionEvent) -> Dialog:
    return Dialog(
        body=Card(sections=[Section(widgets=[TextInput(name="email", label="Email")])])
    )

Dialogs are visible only to the user who opened them. They are HTTP-only synchronous primitives; Pub/Sub has no channel on which to return a dialog. DateTimePicker is rejected inside a dialog because Google does not support it there.

@router.dialog_cancel() handles cancellation. A submit returns ActionStatus.ok() to close or ActionStatus.invalid() to keep the dialog open with a user-facing error.

App Home

from chattice.events import AppHomeEvent, FormSubmitEvent


@router.app_home()
async def home(event: AppHomeEvent) -> Card:
    return Card(sections=[Section(widgets=[TextParagraph("Welcome")])])


@router.form_submit()
async def update_home(event: FormSubmitEvent) -> Card:
    return Card(sections=[Section(widgets=[TextParagraph("Saved")])])

The HTTP serializer maps these to Google's pushCard and updateCard navigation actions. App Home is a private surface hosted by the app's direct message Space; it is not a public Space publishing target.

Updating and escaping

Returning a Card from a card-click HTTP handler selects Google's sender-sensitive update response. Use bot.app.messages.update(name, card=...) for an authenticated asynchronous update. Unsupported widget kinds can be carried by RawWidget; exact unsupported API methods remain available through bot.raw.app() / bot.raw.user().

Next: Routing and state.

Typed ActionData

Bind typed dataclasses to button parameters without packed callback strings. The action function name is the discriminator; parameters are flat strings:

from dataclasses import dataclass

from chattice.actions import ActionData
from chattice.cards import Button


@dataclass
class Deploy(ActionData, function="deploy"):
    environment: str
    version: str


@router.action("deploy", Deploy.filter())
async def deploy(event: ActionEvent, data: Deploy) -> str:
    return f"Deploying {data.version} to {data.environment}"


Button("Deploy", action=Deploy(environment="prod", version="1.2.3"))

If the incoming parameters cannot decode into the model, the filter does not match — enable the chattice.actions logger at DEBUG level to see the reason (see Troubleshooting).