Stdlib: email & http
External connections live in stdlib modules: use std/email for IMAP/SMTP, use std/http for HTTP. (See also std/db for SQLite access.) The interface boundary is planned so these backends can become swappable; the default email and http transports are the only ones wired today.
email
Default implementation uses imap (fetch) + lettre (send). v0.1 reads credentials from environment variables:
export IMAP_HOST=imap.gmail.com
export SMTP_HOST=smtp.gmail.com # optional — defaults to IMAP host with `imap.` → `smtp.`
export EMAIL_USER=you@example.com
export EMAIL_PASS=app-password
If those aren’t set, email.fetch returns [] and email.send is a no-op (with a stderr warning), so programs keep running.
Fetch messages
emails = email.fetch(unread: true) # up to 20 most recent unread from INBOX
Each returned map has from, subject, body, unread, and uid keys.
The uid is the IMAP UID of the message and is required by email.archive.
Send messages
email.send(reply, to: msg.from)
email.send(reply, to: address, subject: "Re: hello")
Positional body can be a str or a map with body (and optional subject). to: can be an address string or a map with from.
Archive
for msg in email.fetch(unread: true) {
email.archive(msg)
}
Don’t name the loop variable
email.archive(...)inside the body would then resolve against the message instead of the module.
email.archive performs an IMAP UID MOVE on the message, falling back
to COPY + \Deleted + EXPUNGE for servers without the MOVE extension.
The destination folder defaults to Archive; override with the
IMAP_ARCHIVE_FOLDER env var:
export IMAP_ARCHIVE_FOLDER="[Gmail]/All Mail"
The argument must be a message map with a positive uid field — the
shape returned by email.fetch. If credentials are not configured the
call is a silent no-op so programs keep running.
http
Default implementation wraps reqwest.
GET
response = http.get("https://api.example.com/data")
# response: HttpResponse?
if response?.is_ok {
users = response?.json_as[list[User]]() ?? []
}
POST
response = http.post("https://api.example.com/v2/events",
json: {kind: "email_processed", count: 42},
headers: {Authorization: "Bearer {env.require("API_KEY")}"}
)
Full request
response = http.request(
method: POST,
url: "https://api.example.com/v2/classify",
headers: {
Authorization: "Bearer {env.require("API_KEY")}",
"Content-Type": "application/json"
},
body: {text: msg.body},
timeout: 10.seconds
)
Returns: HttpResponse? — see Types for the shape.
http.serve — inbound HTTP (webhooks)
Start an HTTP listener on a port. Each incoming request invokes the handler closure:
http.serve(8080, (request) => {
method = request["method"] # "GET", "POST", …
path = request["path"] # "/webhook/events"
body = request["body"] # raw body string
if method == "POST" {
io.show("Received: {body}")
{ status: 200, body: "OK" }
} else {
{ status: 405, body: "Method Not Allowed" }
}
})
request— map withmethod,path,body(all strings)- Return a map with
status(integer, 100–999) andbody(string) - The server runs in a background task;
http.servereturns immediately - The event loop stays alive as long as at least one server is active, even with no running agents
- When the event queue is full, incoming HTTP requests receive a
503 Service Unavailableresponse automatically. No user code runs for that request.
Handlers run outside any agent context. An
http.servehandler is a top-level closure — it fires on the event loop with nocurrent_agentset. That has two consequences:
self.<field>raises a runtime error inside a handler. Agent state is only reachable from within an agent’s tasks /onhandlers / attribute blocks.ai.*calls are not agent-aware. No@role, no@rules, and no@modelinjection — calls fall back to the default model (KEEL_OLLAMA_MODEL) with a bare system prompt. Results are still returned, just without the agent’s identity layered in.To use agent state or an agent’s
@rolefrom a handler, route the request into a live agent. The matchingon http_request(req) { ... }handler onTriageruns withself.and@roleall wired up.
http.serve(8080, (request) => {
send(Triage, request, event: "http_request")
{ status: 202, body: "accepted" }
})
db
db.connect opens a SQLite database and returns a DbConnection value. All SQL is
executed through that value. Requires @tools [db] inside agents.
use std/db
use std/time
conn = db.connect("sqlite://interactions.db")
cutoff = time.now() - 30.days
rows = conn.query(
"SELECT * FROM interactions WHERE contact = ? AND created_at > ?",
[msg.from, cutoff]
)
# rows: list[map[str, dynamic]]
conn.exec("UPDATE status SET seen = true WHERE id = ?", [ticket.id])
# returns int — number of rows affected
Scope. SQLite only (
sqlite://path,sqlite:///abs/path,sqlite://:memory:).
See The Standard Library for how interface dispatch works.
Why a library, not connect + fetch keywords
Dedicated connect X via Y { ... } or fetch X where Y grammar wouldn’t compose well: connect is really a struct literal, and fetch generalizes badly across connection types (email’s unread is not SQL’s where). Per-connector libraries give better autocomplete, clearer types, and zero language changes when a new connector ships.