A minimal TypeScript app that mimics a chat‑agent using shared actions
Welcome to another official UVF IT build where we craft a tiny agentic app inspired by BuilderIO's Agent‑Native framework. In just ~45 minutes you’ll have a runnable TypeScript project that showcases a shared action used both by a UI and an autonomous agent.
We’ll stay on the free tier: Node.js, SQLite (via better‑sqlite3) and no external paid APIs. The result is a local chat UI that can also be driven by a simple “agent” script.
We start with Vite’s official TypeScript template because it gives us hot‑module reloading, JSX support, and an out‑of‑the‑box dev server. After the scaffold we’ll add the Agent‑Native core and the SQLite driver as dependencies, keeping the repo tiny and ready for the next steps.
npm create vite@latest agentic-chat -- --template react-ts
cd agentic-chat
npm install @builder.io/agent-native better-sqlite3
npm install -D @types/better-sqlite3Agent‑Native treats functions in the src/actions folder as remote‑callable actions. We’ll create sendMessage.ts that inserts a new chat line into a SQLite table and then returns the entire conversation so both UI and agent see the same state.
src/actions/sendMessage.ts
import { action } from '@builder.io/agent-native';
import Database from 'better-sqlite3';
// Initialise (or open) a local SQLite file in the project root.
const db = new Database('chat.db');
// Ensure the messages table exists.
db.exec(`CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`);
/**
* Append a new chat entry and fetch the full history.
* @param role – "user" or "assistant"
* @param content – the message text
*/
export const sendMessage = action(async (role: 'user' | 'assistant', content: string) => {
const insert = db.prepare('INSERT INTO messages (role, content) VALUES (?, ?)');
insert.run(role, content);
const rows = db.prepare('SELECT role, content FROM messages ORDER BY id').all();
return rows; // [{role, content}, …]
});Agent‑Native already spun up an Express server that registers our TypeScript action at /api/chat. All we have to do is build a very small React component that talks to that endpoint. The UI will keep a list of messages and let the user type a new one, then POST it to the server and display the assistant’s reply.
We keep the component self‑contained so it can be dropped into the src/ui folder created by the scaffold. It uses the browser fetch API, so no extra dependencies are required. The component also handles loading state so the user sees when the assistant is thinking.
src/ui/Chat.tsx
import React, { useState, useEffect } from "react";
interface Message {
role: "user" | "assistant";
content: string;
}
export default function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const sendMessage = async () => {
if (!input.trim()) return;
const userMsg: Message = { role: "user", content: input };
setMessages(prev => [...prev, userMsg]);
setInput("");
setLoading(true);
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [...messages, userMsg] })
});
const data = await res.json();
const assistantMsg: Message = { role: "assistant", content: data.reply };
setMessages(prev => [...prev, assistantMsg]);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
return (
<div style={{ maxWidth: 600, margin: "0 auto", padding: 20 }}>
<h2>Agent‑Native Chat</h2>
<div style={{ border: "1px solid #ccc", minHeight: 300, padding: 10 }}>
{messages.map((msg, i) => (
<div key={i} style={{ marginBottom: 8 }}>
<strong>{msg.role}:</strong> {msg.content}
</div>
))}
{loading && <div>🤖 thinking...</div>}
</div>
<textarea
rows={2}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKey}
placeholder="Type a message..."
style={{ width: "100%", marginTop: 10 }}
/>
<button onClick={sendMessage} disabled={loading} style={{ marginTop: 5 }}>
Send
</button>
</div>
);
}The scaffold ships a Vite‑powered dev script that builds the React UI and starts the Express backend on the same port. Running it in watch mode gives you hot‑reloading for the UI and instant reload of the action when you change TypeScript files.
Just fire up the npm script, wait for the console to announce the server is listening, then open your browser at the printed URL. All communication stays on localhost, no extra configuration needed.
npm install
npm run devNow we prove that the same /api/chat endpoint can be called from a pure Node script, mimicking an autonomous agent. The script sends a static user message, receives the assistant reply, and logs it – you could later replace the static payload with a loop that reads from a queue or triggers on events.
Because the Express server is already running, the agent only needs node-fetch (included in Node 18+ as global fetch) and the same request shape the UI uses. This demonstrates the core idea: one action, two consumers.
agent.ts
// agent.ts – a tiny headless client for the same chat action
interface Message {
role: "user" | "assistant";
content: string;
}
async function callAgent(messages: Message[]) {
const res = await fetch("http://localhost:3000/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages })
});
const data = await res.json();
return data.reply as string;
}
async function main() {
const userMsg: Message = { role: "user", content: "Hello from headless agent!" };
console.log("👤", userMsg.content);
const reply = await callAgent([userMsg]);
console.log("🤖", reply);
}
main().catch(err => console.error(err));| Check | Command / Why it matters |
|---|---|
| Project scaffolds without errors | npx create-simple-agent --standalone --template chat (should finish with 'Done') |
| npm run dev starts a server on http://localhost:3000 | npm run dev (look for 'Server listening on http://localhost:3000') |
| Chat UI shows an empty message list and can add a user message | Open browser, type text, click Send – message appears in list |
| Running node agent.ts adds assistant messages to the list | node agent.ts (observe console logs and UI updates) |
You now have a functional minimal agentic app where the same TypeScript action powers both a React chat UI and a headless agent script. From here you can swap the static reply for a real LLM call, add authentication, or persist the DB elsewhere. Happy hacking!