System operations
This page documents the operations that do not touch chats: service calls, storage, timers, the mechanics of ui.* calls, badges and notifications. It also describes the call envelope, the async mechanism and the common limits. Everything here follows the host code (broker.cpp and friends); where a value is a limit, it is the value the host enforces.
Related pages: Call protocol (ops) (summary), Network operations, Files, media and images, Text formatting, Errors and limits, Async.
Call envelope
Section titled “Call envelope”Every call is a map with the operation name and its arguments:
# raw form: {"op": <name>, "a": {<arguments>}, "async": <bool, optional>}r = freshgram.call({"op": "storage.get", "a": {"key": "counter"}})# -> {"ok": True, "v": 3} or {"ok": False, "e": "denied", "m": "storage.private"}| Field | Type | Meaning |
|---|---|---|
op |
text | operation name. Missing or not text: invalid (“no operation”) |
a |
map | arguments. May be omitted (then all arguments are absent). Present but not a map: invalid (“arguments must be a map”) |
async |
bool | true makes the call asynchronous. Only a real true counts; any other value is ignored |
The answer is {"ok": true, "v": <result>} or {"ok": false, "e": "<code>", "m": "<message>"}. The Python wrappers return v and raise an exception that matches e (see Errors and limits). Codes used by this page: denied, invalid, not_found, quota, limit, unavailable, failed; network operations add transport codes (see Network operations).
Order of checks
Section titled “Order of checks”For every call the host checks, in this order:
- Global call budget: more than 200 calls in the last second:
limit“too many calls”. - If
asyncistrue: the async path. - Hook gate: while the plugin handles
message.outgoing/message.incoming, onlymeta.info,log,time.nowandmessages.set_outgoing,messages.drop_outgoing,messages.set_incoming,messages.hide_incomingare allowed. Everything else:unavailable“not while changing an outgoing message”. - Await-only gate:
time.sleep,files.pick,files.saveand everyfs.*called withoutasync:invalid“must be awaited”. - Per-operation rate limit (see limits). It is counted before permission and argument checks, so a denied or invalid call still uses up its slot.
- Permission check, then argument validation, then the work.
Common limits
Section titled “Common limits”| Limit | Value | Error |
|---|---|---|
| Calls per second, per plugin instance (one plugin and account) | 200 (sliding one second window), asynchronous calls included | limit “too many calls” |
| Pending async calls | 32 | limit “too many calls are pending” |
| Host calls per event dispatch | 256 | limit “too many calls for one event” |
| Total time spent inside host calls per event | 60 s | limit “the host calls of this event took too long” |
| Event run time (without host call time) | runtime.limits.eventTimeoutMs, 10 to 60000 ms, default 500 |
the event is aborted |
| One request or response frame | 128 MiB | an answer over the limit is replaced by limit “the answer is too large” |
Result of an async call (call.result) |
128 MiB minus 1 KiB | limit “the answer is too large” |
net.fetch request body / response body |
100 MiB / 100 MiB | limit |
| Plugin storage value / keys / quota | 1 MiB / 4096 / runtime.limits.storageMb (0 to 1024 MiB, default 10) |
limit / quota / quota |
Files read or written through files.* or fs.*, one call |
100 MiB | limit |
Media returned by media.get |
100 MiB | limit |
| Image input / output / side / pixels | 16 MiB / 32 MiB / 8192 px / 24 Mpx | limit |
| Clipboard text | 64 KiB | invalid (write) or limit (read) |
| Open sockets | 16 per kind (TCP, UDP, WebSocket), per plugin instance | limit “too many open sockets” |
One socket chunk (net.socket.send) |
1 MiB (TCP), 65507 bytes (UDP) | limit |
| WebSocket message | 1 MiB | limit |
Plugin message (plugins.send) |
64 KiB encoded, depth 12 | limit / invalid |
Message text (messages.send and similar) |
4096 bytes UTF-8 (caption 4096 bytes) | invalid |
| Timers | 32 per plugin and account | limit |
Per-operation rate limits (all measured on the plugin instance):
| Operations | Limit |
|---|---|
messages.send, stickers.send, messages.forward, messages.send_media, messages.edit_media, messages.send_poll |
burst of 5, refilled one per second (shared bucket) |
messages.edit, messages.delete, messages.react, chats.mark_read, users.photo, channels.photo |
burst of 20, refilled one per 200 ms (shared bucket) |
ui.toast |
one per 2 s |
notify.show |
one per 5 s |
image.* |
one per 100 ms (shared by all four) |
users.resolve, channels.resolve |
one per 1 s (shared); limit “usernames are resolved too often” |
chats.ban, chats.unban |
one per 1 s (shared); limit “members are moderated too often” |
chats.members, chats.admins |
one per 1 s (shared); limit “members are listed too often” |
bots.inline_query, bots.press_button |
one per 1 s (shared); limit “bots are asked too often” |
ui.dialog.show, new ui.window.show |
after a user action, or at most once per 60 s |
ui.window.update (and show of an open window) |
one per 100 ms per window |
fs.write |
512 MiB written per rolling 60 s |
| datagrams of one UDP socket | 200 per second |
An operation that hits its rate limit answers limit. Message-level operations are documented in the message and chat API pages.
The async mechanism
Section titled “The async mechanism”A synchronous call blocks the handler until the host answers. With "async": true the host answers at once with a ticket, does the work on a background thread, and later delivers the result as an event.
# Python (freshgram.aio does all of this for you)from freshgram import aiodata = await aio.storage.get("k")
# raw formack = freshgram.call({"op": "net.fetch", "a": {"url": "https://example.org"}, "async": True})# ack == {"ok": True, "v": {"ticket": 7}}The immediate answer is {"ok": true, "v": {"ticket": N}}. Later the plugin receives:
{"type": "call.result", "ticket": 7, "ok": true, "v": <result>}{"type": "call.result", "ticket": 7, "ok": false, "e": "<code>", "m": "<message>"}That is, call.result is the normal answer map (ok, v or e and m) with type and ticket added.
| Property | Behavior |
|---|---|
| Tickets | integers starting at 1, increasing, unique per plugin instance (plugin and account) |
| Max pending | 32 per plugin instance. The 33rd async call is refused at once with limit “too many calls are pending” (no ticket) |
| Budget | an async call also uses one of the 200 calls per second |
| Errors at submit time | refused immediately in the ack (ok: false): limit (budget or pending), unavailable (hook active, or async not available: “awaitable calls”), invalid (op cannot be awaited; time.sleep with a bad ms) |
| Errors at run time | arrive in call.result (denied, invalid, quota, rate limits and so on), because the operation runs later. Rate limits are evaluated when the work starts |
| Work threads | one pool of 8 threads shared by all plugins; socket calls (net.tcp.*, net.udp.*, net.ws.*, net.socket.*) run in order on the plugin’s own socket thread |
| Cancellation | there is no per-call cancellation. aio.wait_for stops waiting but the call still runs and its result is dropped. When the plugin stops or unloads, pending calls are abandoned and no call.result is delivered |
| Oversized result | replaced by limit “the answer is too large” |
| Hooks | async calls are refused (unavailable) while a message hook is handled |
Which operations support async
Section titled “Which operations support async”| Operation | Sync | Async |
|---|---|---|
time.sleep |
no (invalid, “must be awaited”) |
yes, only async |
files.pick, files.save, fs.list, fs.stat, fs.read, fs.write, fs.mkdir, fs.remove, fs.rename |
no (invalid, “must be awaited”) |
yes, only async |
plugin.unload, messages.set_outgoing, messages.drop_outgoing, messages.set_incoming, messages.hide_incoming |
yes | no (invalid “this call cannot be awaited”) |
| every other operation | yes | yes |
In the Python SDK freshgram.files and freshgram.fs exist only as aio.files and aio.fs; aio.sleep(seconds) sends time.sleep with ms = max(0, int(seconds * 1000)). It is not clamped: more than 60 seconds raises Invalid.
Service operations
Section titled “Service operations”meta.info
Section titled “meta.info”No permission. Allowed inside hooks.
| Argument | Type | Required |
|---|---|---|
| none |
Result:
{"api": 7, "plugin": "<plugin id>", "granted": ["storage.private", ...], "chats": "all" | [chat ids]}| Field | Meaning |
|---|---|
api |
host API version (7) |
plugin |
the plugin id |
granted |
permissions that are actually granted now (sorted) |
chats |
"all", or the list of chat ids the user selected for the plugin |
freshgram.info() # raw: {"op": "meta.info"}Errors: none beyond the common ones.
No permission. Allowed inside hooks.
| Argument | Type | Required | Validation |
|---|---|---|---|
text |
text | yes | up to 4096 bytes; otherwise invalid “text” |
Result: null. The line is added to the plugin log: only the first 500 bytes are kept, the log keeps the last 200 lines (the app may show or forward them). The Python freshgram.log(text) shortens text to its last 4000 bytes before sending. Python print goes to the error stream and is separate.
freshgram.log("started") # raw: {"op": "log", "a": {"text": "started"}}time.now
Section titled “time.now”No permission. Allowed inside hooks. No arguments.
Result: integer, unix time in seconds, server-corrected (not the device clock); falls back to the system clock when the app does not provide a server time.
freshgram.time() # raw: {"op": "time.now"}time.sleep
Section titled “time.sleep”No permission. Async only (a sync call answers invalid “time.sleep must be awaited”).
| Argument | Type | Required | Validation |
|---|---|---|---|
ms |
integer | yes | 0 to 60000; otherwise invalid “ms” (the error is in the ack) |
Result when the time has passed: null. It uses a ticket and counts toward the 32 pending calls but does not occupy a worker thread.
await aio.sleep(1.5) # raw: {"op": "time.sleep", "a": {"ms": 1500}, "async": true}plugin.unload
Section titled “plugin.unload”No permission. Sync only. No arguments. Result: null.
Marks the instance for unloading: the plugin stops after the current event finishes, and the app starts it again on the next event, command, menu click or settings change that activates it. Pending async calls are abandoned. Timers survive an unload (they are removed when the plugin is disabled, uninstalled, or the account logs out).
freshgram.unload() # raw: {"op": "plugin.unload"}Storage
Section titled “Storage”Permission: storage.private for all storage.* operations (denied “storage.private” otherwise). The storage is a key to value map private to the plugin.
Properties:
- One store per plugin (shared by all the accounts of that plugin), kept in the plugin’s
data/store.cbor. It is written atomically and the previous good copy is kept as a backup; a damaged file is restored from the backup (the loss is written to the plugin log). - Values are stored as CBOR. Allowed value types:
null, bool, integer (64-bit), float, bytes, text (valid UTF-8), array, map with text keys. In Python:None,bool,int,float,bytes,str,list,dict[str, ...]. Nesting deeper than 16 levels is rejected (reported aslimit). Bytes and text stay distinct types. - Size limits: encoded value up to 1 MiB; key 1 to 128 bytes; at most 4096 keys.
- Key rules: non-empty text, at most 128 bytes (UTF-8 bytes, not characters), no byte below
0x20and no0x7F. Anything else isinvalid“key”. Keys are compared byte by byte;storage.listreturns them in that order. - Quota semantics: the storage uses
len(key) + len(CBOR value)for every entry. The quota isruntime.limits.storageMbMiB from the manifest (default 10, maximum 1024;0makes every write fail). A write that replaces a key counts the new size minus the old size of that entry. The check and the write are one atomic step.
storage.get
Section titled “storage.get”| Argument | Type | Required | Validation |
|---|---|---|---|
key |
text | yes | key rules above, else invalid “key” |
Result: the stored value, or null when the key does not exist. Errors: denied, invalid “key”, failed “a damaged value” (stored bytes do not decode), unavailable “storage”.
n = freshgram.storage.get("counter", 0) # default is applied by the SDK when the result is null# raw: {"op": "storage.get", "a": {"key": "counter"}}A stored null cannot be told apart from a missing key.
storage.set
Section titled “storage.set”| Argument | Type | Required | Validation |
|---|---|---|---|
key |
text | yes | key rules |
value |
any | yes | must be present (null is allowed); invalid “value” if absent |
Result: null.
| Error | Trigger |
|---|---|
invalid “key” / “value” |
bad key; value absent |
limit “the value is too large” |
encoded value over 1 MiB, or it cannot be encoded (too deep) |
quota “too many keys” |
a new key while 4096 keys exist |
quota “the quota is used up” |
used bytes minus the old entry plus the new entry exceeds the quota |
freshgram.storage.set("counter", 4)# raw: {"op": "storage.set", "a": {"key": "counter", "value": 4}}storage.delete
Section titled “storage.delete”| Argument | Type | Required |
|---|---|---|
key |
text | yes |
Result: bool, true if the key existed. Deleting a missing key is not an error. A missing, non-text or invalid key (see the key rules) is invalid “key”.
storage.list
Section titled “storage.list”| Argument | Type | Required | Default | Validation |
|---|---|---|---|---|
prefix |
text | no | "" |
up to 128 bytes, else invalid “prefix or limit” |
limit |
integer | no | 100 | below 1 is invalid; values over 1000 are clamped to 1000; a non-integer is silently ignored (100), it is not invalid |
Result: array of key texts, sorted, all starting with prefix.
freshgram.storage.keys("user:", limit=50)# raw: {"op": "storage.list", "a": {"prefix": "user:", "limit": 50}}storage.usage
Section titled “storage.usage”No arguments. Result: {"used": <bytes>, "quota": <bytes>, "keys": <count>}.
Timers
Section titled “Timers”No permission is checked by the host. Timers are held in memory while the app runs (they are not saved across app restarts) and wake the plugin with a timer event {"type": "timer", "id": "<id>"}, starting it if it is not running. A one-shot timer is removed when it fires; a repeating timer fires again after each period, counted from the moment it fired. Timers are removed when the plugin is disabled or uninstalled and when the account logs out.
timers.set
Section titled “timers.set”| Argument | Type | Required | Default | Validation |
|---|---|---|---|---|
id |
text | yes | 1 to 64 bytes, no control characters; else invalid “id” |
|
delay |
integer, ms | yes | 1000 to 604800000 (7 days); else invalid “delay must be from 1000 ms to 7 days” |
|
repeat |
bool | no | false |
not a bool: invalid (same message) |
Result: null. Setting an existing id replaces that timer. A 33rd distinct timer: limit “the timer was not set”. unavailable “timers” if the app has no timer service.
freshgram.timers.set("tick", 60, repeat=True) # seconds in Python, converted to ms# raw: {"op": "timers.set", "a": {"id": "tick", "delay": 60000, "repeat": true}}The Python wrapper sends delay = int(seconds * 1000) and omits repeat when it is false.
timers.clear
Section titled “timers.clear”| Argument | Type | Required |
|---|---|---|
id |
text | yes (same rules as above) |
Result: bool, true if a timer was removed.
timers.list
Section titled “timers.list”No arguments. Result: array of the ids of the timers that are currently set.
Interface operations (mechanics)
Section titled “Interface operations (mechanics)”The item schema of pages, dialogs and windows is documented in UI. This section covers permissions, arguments around the page, and limits.
A user action is a click-like event delivered to the plugin: menu.click, badge.click, command, settings.*, toast.click, dialog.click, dialog.change, dialog.long_click, window.click, window.change, window.long_click. Closing events (dialog.close, window.close, toast.close) are not user actions. A user action stays “recent” for 10 seconds.
ui.toast
Section titled “ui.toast”Permission ui.toast.
| Argument | Type | Required | Default | Validation |
|---|---|---|---|---|
text |
text | yes | up to 200 bytes (empty is allowed) | |
title |
text | no | none | up to 64 bytes, no newline |
style |
text | no | "default" |
"default" or "undo" |
duration |
integer, seconds | no | 5 | 1 to 8 |
action |
map | no | none | {"id": text 1-32 bytes, "label": text up to 40 bytes}; label is required unless style is "undo" |
style: "undo" requires an action; without one the call fails with invalid “undo needs an action”. Result: null. The shown text is prefixed with the plugin id. A click on the button arrives as toast.click, the end of the toast as toast.close with reason "action" or "timeout"; the host remembers the last 16 offered action ids.
Errors: denied “ui.toast”; invalid “text”, “style”, “duration”, “title”, “action”, “undo needs an action”; limit “toasts are shown too often” (one per 2 s); unavailable “toast actions” (the app cannot offer toast buttons) or other unavailable.
freshgram.ui.toast("Saved", action="undo-1", undo=True, duration=6)# raw: {"op": "ui.toast", "a": {"text": "Saved", "style": "undo", "duration": 6, "action": {"id": "undo-1"}}}ui.settings.set
Section titled “ui.settings.set”Permission ui.settings_page. Argument page (map, required): validated against the page schema; a bad page is invalid with the validation message. Result null. unavailable “settings page” if the manifest declares no settings page. No rate limit.
ui.open_settings
Section titled “ui.open_settings”Permission ui.settings_page. No arguments. Allowed only within 10 seconds after a user action, and it consumes that action: otherwise denied “only right after a user action”. Result null.
ui.dialog.show, ui.dialog.close
Section titled “ui.dialog.show, ui.dialog.close”Permission ui.dialog (denied “ui.dialog”; unavailable “dialogs” without a UI service).
| Operation | Arguments | Notes |
|---|---|---|
ui.dialog.show |
page (map, required) |
page schema errors are invalid. Rate limit: refused with limit “dialogs open after a user action” when no dialog is open, there was no user action in the last 10 s, and a dialog or window was opened in the last 60 s |
ui.dialog.close |
none | always null; resets the “dialog open” state |
Result of show: null.
ui.window.show, ui.window.update, ui.window.close
Section titled “ui.window.show, ui.window.update, ui.window.close”Permission ui.dialog.
| Argument | Type | Required | Validation |
|---|---|---|---|
id |
text | yes | up to 32 characters of a-z 0-9 _ - .; else invalid “id” |
page |
map | show, update |
page schema |
mode |
text | no (show) |
"layer" (default) or "window"; an already open window keeps its mode |
Rules:
- At most 3 windows are open at once: another new one is
limit“too many windows are open”. - Opening a new window follows the same rule as dialogs: a user action in the last 10 s, or no dialog/window opened in the last 60 s; else
limit“windows open after a user action”. - Showing or updating an open window is limited to one change per 100 ms per window:
limit“windows are updated too often”. ui.window.updatefor a window that is not open:not_found“the window is not open”.unavailable“windows” if the app could not show the window (a window opened by this call is not counted).ui.window.closealways answersnull.
Results are null.
freshgram.ui.window("stats", "Stats", items, mode="window")# raw: {"op": "ui.window.show", "a": {"id": "stats", "mode": "window", "page": {"title": "Stats", "items": [...]}}}Badges
Section titled “Badges”Permission ui.badges.
badges.set
Section titled “badges.set”| Argument | Type | Required | Validation |
|---|---|---|---|
user |
integer | yes | greater than 0, not the service account 777000 (denied “this user”); else invalid “user” |
items |
array | yes | up to 4 entries; an empty array removes the badges of that user |
Each item is a map with exactly the keys id, icon, image, tooltip:
| Key | Rule |
|---|---|
id |
required, unique inside the list, up to 32 characters of a-z 0-9 _ - . |
icon |
built-in or plugin icon name, 1 to 32 characters of a-z 0-9 _ - |
image |
bytes, up to 64 KiB. PNG with both sides at most 128 px, or JPEG (size checked later by the app) |
tooltip |
optional text, up to 80 bytes |
Exactly one of icon and image per item. A bad list is invalid with the message. Setting badges for a 257th distinct user is quota “too many users have badges” (256 users at most). Result null; unavailable “badges” without a UI service. A click on a badge arrives as badge.click.
freshgram.badges.set(12345, [{"id": "vip", "icon": "star", "tooltip": "VIP"}])# raw: {"op": "badges.set", "a": {"user": 12345, "items": [{"id": "vip", "icon": "star", "tooltip": "VIP"}]}}badges.clear
Section titled “badges.clear”No arguments. Removes every badge of the plugin and frees all 256 slots. Result null.
Notifications
Section titled “Notifications”notify.show
Section titled “notify.show”Permission notifications.
| Argument | Type | Required | Validation |
|---|---|---|---|
text |
text | yes | 1 to 256 bytes |
title |
text | no | up to 64 bytes (default: the app name) |
Result null. Rate limit: one per 5 s (limit “notifications are shown too often”). invalid “title or text”; not_found if the account is gone; unavailable if the app did not answer in time (about 1.5 s); if the app refuses, the code it reports (usually failed) with the message “the notification was not shown”. The app shows a system notification when native notifications are enabled, otherwise a toast.
freshgram.notify.show("Done", title="Export")# raw: {"op": "notify.show", "a": {"text": "Done", "title": "Export"}}For clipboard.*, files.*, fs.*, media.* and image.* see Files, media and images; for net.* see Network operations.
