Skip to content

C SDK

The whole SDK is one file, Telegram/plugins_sdk/c/freshgram.h. It has no dependencies and no libc: it contains a CBOR reader and writer, a bump allocator, the exports the host needs (fg_alloc, fg_free, fg_init, fg_on_event), the import of the host call, and ready-made wrappers for the most common operations. The wire format and limits are in Wasm ABI; the operations are in Call protocol.

Terminal window
clang --target=wasm32 -O2 -nostdlib -fno-builtin -Wl,--no-entry -o plugin.wasm plugin.c
#include "freshgram.h" // in exactly ONE .c file
int fg_plugin_init(fg_value info) { (void)info; fg_log("ready"); return 0; }
int fg_plugin_event(fg_value event) { (void)event; return 0; }

Include the header in one translation unit only. It defines static state (the heap) and the exported functions, so a second inclusion in another .c file would give a second heap and duplicate exports. Add -DFG_HEAP_BYTES=... to the command line, or #define it before the #include, to change the heap size.

Then declare the plugin in manifest.json with "runtime": { "kind": "wasm", "module": "plugin.wasm", "language": "c" }; see Wasm quickstart.

All memory comes from one static array fg_heap_ of FG_HEAP_BYTES bytes (1 MiB by default, 8-byte alignment). It is part of the module’s linear memory, so it counts against memoryMb of the manifest.

The allocator is a bump pointer. Nothing is released one block at a time:

Rule Detail
Allocation fg_arena(size) moves the pointer forward; it returns 0 when the rest of the heap is too small
Free The exported fg_free ignores its arguments and resets the whole arena. The host calls it after fg_init / fg_on_event returns normally, on the input block. So memory lives until the end of the current event
The event itself The host puts the event (or the info map) into the arena with fg_alloc before calling you. It is the first block, so it is part of the budget
Each request fg_begin / fg_begin_async call fg_writer_init(w, 4096): 4096 bytes are reserved for every request, however short, and are never given back during the event
Growth fg_grow doubles the capacity (4096, 8192, …), allocates a new block and copies; the old block is abandoned
Answers The host allocates every answer in the same arena (through fg_alloc) and never frees it

Consequences:

  • About 256 calls per event exhaust a 1 MiB heap (256 x 4096 bytes), even before answers are counted. The host also allows only 256 calls per event, so in practice the heap and the call budget run out together. If you need more room, raise FG_HEAP_BYTES.
  • A single request cannot be larger than about 512 KiB: fg_grow refuses to double once the capacity is above FG_HEAP_BYTES / 2, and the abandoned smaller blocks use the other half. This matters for fg_send_media, fg_edit_media, fg_socket_send. Raise FG_HEAP_BYTES for bigger payloads.
  • An answer plus a request plus the event must all fit. When fg_alloc returns 0 for an input block the event is dropped as a failure; when it fails for an answer the host call returns -4 and the wrapper returns FG_TRANSPORT (see Wasm ABI).
  • If the 4096-byte reservation fails, the writer is not marked failed: fg_writer_init leaves data = 0, capacity = 0, failed = 0. The first write then goes to fg_grow, which starts at 64 bytes and may still succeed if a small piece of heap is left. Only when fg_grow fails does the writer become failed, and then fg_send returns FG_TRANSPORT without calling the host.
  • Do not call fg_free yourself inside a handler. It resets the arena, so the event you are handling, every fg_value returned by fg_find or fg_send, and every text pointer from fg_as_text become garbage as soon as the next allocation overwrites them. Copy what you need into your own static buffers first.
  • Static variables survive between events, but the instance can be stopped and restarted (unload, crash, update). Keep durable state in storage.*.

memcpy and memset are provided as weak symbols (simple byte loops), because -nostdlib -fno-builtin leaves no libc. Define your own, or link a better implementation, and yours wins.

Name Meaning
FG_EXPORT(name) __attribute__((export_name(name))), to export a function from the module
FG_HOST_CALL __attribute__((import_module("freshgram"), import_name("call")))
fg_host_call(request, length, &response, &response_length) The only import. request is CBOR, the answer is written into a block the host allocated with fg_alloc
FG_HOST_CALL int fg_host_call(
const void *request, fg_u32 length,
void **response, fg_u32 *response_length);

It returns 0 when an answer was written (the answer may still be {"ok": false, ...}), or a negative number when the call did not happen: -1 bad arguments, -2 the request is not valid CBOR, -3 the answer could not be encoded, -4 no memory for the answer, -5 the per-event call budget is used up. See Wasm ABI.

fg_send does not keep these numbers: any nonzero result (and a zero result with a null response) becomes FG_TRANSPORT. If you must tell -5 from -2, call fg_host_call yourself.

The header exports fg_alloc, fg_free, fg_init, fg_on_event for the host. You never call them.

You define:

int fg_plugin_init(fg_value info);
int fg_plugin_event(fg_value event);

fg_init calls fg_plugin_init once when the plugin starts; fg_on_event calls fg_plugin_event for every event, one at a time (never re-entered). The return value is the event status. 0 means success; a nonzero value is not a crash, see Return values. For fg_plugin_init a nonzero value writes failure: the start handler returned N to the plugin log; for ordinary events the host ignores the status; for message hooks (message.outgoing, message.incoming) a nonzero status discards your edit (and a blocking hook refuses the message). So return 0 from a hook handler after a successful edit.

info is a map (not the result of meta.info):

Key Type Meaning
account text Id of the account this instance runs for, a decimal string such as "3"
plugin text Plugin id from the manifest
version text Installed plugin version
api int Host API level

Every event is a map with a type text key. Which events you get depends on the manifest and the permissions: message.new, command, menu.click, timer, toast.click, plugin.message, translate.request, ai.request, message.outgoing, call.result and others. The keys of each are in Event payloads; the list and permissions are in Events.

C has no string compare in libc, so write a small helper:

static int fg_is(fg_value v, const char *wanted) {
const char *t = 0; fg_u32 n = 0;
if (fg_as_text(v, &t, &n) != 0) return 0;
for (fg_u32 i = 0; i < n; ++i) if (!wanted[i] || t[i] != wanted[i]) return 0;
return wanted[n] == 0;
}
int fg_plugin_event(fg_value event) {
if (fg_is(fg_find(event, "type"), "command")) { /* ... */ }
return 0;
}
typedef struct { const fg_u8 *p; const fg_u8 *end; } fg_value;

An fg_value is a pointer to the start of one CBOR item plus the end of the buffer it lives in (so it is not trimmed to the item’s own length). It points into the event or the answer; it owns nothing.

Function Meaning
fg_none() An empty value (p = end = 0)
fg_valid(v) Nonzero if v points at something (p set and p < end). A found key whose value is null is still valid; test it with the fg_as_* functions
fg_find(map, key) Look up a text key in a map, return its value, or fg_none() if map is not a map, the key is absent, or the data is malformed. Keys are compared byte by byte; the first match wins
fg_as_int(v, &out) Integer (CBOR major 0 or 1) into fg_i64. Floats are not accepted. Values above INT64_MAX wrap
fg_as_bool(v, &out) true / false into int (1 / 0)
fg_as_text(v, &ptr, &len) UTF-8 text: pointer into the buffer and length in bytes

All three fg_as_* return 0 on success and -1 on failure (wrong type, fg_none(), malformed or truncated data), and then leave their output untouched, so initialize it:

fg_i64 chat = 0;
if (fg_as_int(fg_find(event, "chat"), &chat) != 0) { /* key absent or not an int */ }

Notes:

  • A missing optional key is simply absent in the map, so fg_find gives fg_none() and the fg_as_* call fails with -1. Optional keys are not null.
  • The text pointer is not NUL-terminated and points into the arena. It stays valid until fg_free runs (the end of the event). Do not compare it with strcmp; use the length.
  • There is no fg_as_bytes, no float reader and no array iterator. fg_as_text rejects byte strings (CBOR major 2), so net.fetch bodies and socket data cannot be read with it. The internal helpers fg_head and fg_skip are available in the header and are enough to write these:
// bytes: CBOR major 2
static int fg_as_bytes(fg_value v, const fg_u8 **data, fg_u32 *length) {
const fg_u8 *p = v.p; int major = 0; fg_u64 n = 0;
if (!fg_valid(v) || fg_head(&p, v.end, &major, &n) != 0
|| major != 2 || n > (fg_u64)(v.end - p)) return -1;
*data = p; *length = (fg_u32)n; return 0;
}
// array: call for each element; returns the element count or -1
static int fg_each(fg_value array, fg_value *items, int max) {
const fg_u8 *p = array.p; int major = 0; fg_u64 n = 0; int got = 0;
if (!fg_valid(array) || fg_head(&p, array.end, &major, &n) != 0 || major != 4) return -1;
for (fg_u64 i = 0; i < n; ++i) {
if (got < max) { fg_value it = { p, array.end }; items[got++] = it; }
if (fg_skip(&p, array.end, 0) != 0) return -1;
}
return got;
}
typedef struct { fg_u8 *data; fg_u32 size; fg_u32 capacity; int failed; } fg_writer;
Function Writes
fg_w_map(w, count) Map header; count is the number of key-value pairs
fg_w_array(w, count) Array header; count is the number of elements
fg_w_text(w, text) NUL-terminated UTF-8 text. Must be valid UTF-8, the host rejects anything else
fg_w_text_n(w, text, length) Text of length bytes (no NUL needed)
fg_w_bytes(w, data, length) Byte string
fg_w_int(w, value) Integer (fg_i64, negative allowed)
fg_w_bool(w, value) true if value is nonzero
fg_w_null(w) null

The header writes only the container head: you must write exactly count entries after fg_w_map / fg_w_array, and exactly arguments pairs after fg_begin. A wrong count gives invalid CBOR: the host answers -2 and you get FG_TRANSPORT, or the request is read with a shifted structure and fails with invalid. Keys and values are written one after the other: fg_w_text(&w, "chat"); fg_w_int(&w, chat); is one pair. Never pass a null const char * to fg_w_text (it would crash the module).

The writer has no error return; once it runs out of memory it sets failed and ignores further writes.

fg_writer w;
fg_begin(&w, "messages.send", 2); // operation, number of argument pairs
fg_w_text(&w, "chat"); fg_w_int(&w, chat);
fg_w_text(&w, "text"); fg_w_text(&w, "hi");
fg_value result = fg_none();
int code = fg_send(&w, &result); // FG_OK or an error code

fg_begin(w, op, n) reserves the 4096 bytes and writes {"op": op}; if n is nonzero it also writes "a" and a map header of n pairs. fg_send returns FG_OK and stores the v field of the answer in *result (pass 0 to ignore it). result is fg_none() if the answer had no v, and a valid null value if the answer had v: null. The result points into the arena; read it before the event ends.

fg_writer w;
fg_begin_async(&w, "net.fetch", 1); // adds "async": true
fg_w_text(&w, "url"); fg_w_text(&w, "https://example.org/");
fg_i64 ticket = 0;
int code = fg_send_async(&w, &ticket); // the call is queued

fg_send_async returns FG_OK and writes the ticket (FG_FAILED if the answer has no integer ticket). The real result arrives later as an event:

{ "type": "call.result", "ticket": N, "ok": true, "v": ... }
{ "type": "call.result", "ticket": N, "ok": false, "e": "denied", "m": "..." }

Match ticket against the one you stored (keep it in a static variable). Errors found at submit time (limit with 32 calls pending, invalid for an operation that cannot be awaited) come back from fg_send_async itself; errors at run time come in call.result. Some operations must be async (time.sleep, files.*, fs.*), others cannot be (plugin.unload, the *_outgoing and *_incoming hook calls). See Async calls and Call protocol.

fg_send, fg_send_async and every wrapper return one of:

Constant Value Meaning
FG_OK 0 Success
FG_DENIED 1 Missing permission, or the chat is out of the granted scope, or the action is not allowed
FG_INVALID 2 A bad, missing or out-of-range argument; also an operation called in the wrong place (a hook call outside its hook)
FG_NOT_FOUND 3 Chat, message, socket or ticket not known
FG_QUOTA 4 Storage quota or key count exceeded
FG_LIMIT 5 Rate limit, size limit, or too many pending calls or sockets
FG_UNAVAILABLE 6 The app did not answer, or the operation is not possible now (for example inside a message hook)
FG_FAILED 7 The server or the network refused or failed; also an unparsable answer
FG_TRANSPORT 8 The call did not happen: the writer failed (out of arena), or fg_host_call returned anything nonzero, or the response was null

fg_error_code(response) maps the answer’s e text: denied, invalid, not_found, quota, limit, unavailable become 1 to 6. Everything else becomes FG_FAILED, including failed itself, cancelled, and the network sub-codes (blocked, dns, connect, tls, timeout, closed, write, response, redirect). The message m is dropped too. If you need the detail, send the request by hand with fg_host_call and read e / m yourself. See Errors and limits.

Each wrapper builds one request and calls fg_send; the return value is an error code above. Strings are NUL-terminated UTF-8 and must not be NULL unless the table says so. Every call spends one of the 256 calls of the event and 4096 bytes of the arena. Limits come from messaging, system, network and social operations; permissions are described in Permissions.

A wrapper that takes fg_i64 *id or fg_value *result only writes it on FG_OK.

Function Operation Permission Notes
fg_log(text) log none text up to 4096 bytes, only the first 500 are kept in the log. Works inside hooks
fg_unload() plugin.unload none The instance stops after the current event; the app starts it again on the next event. Sync only
fg_storage_get_int(key, &out) storage.get storage.private Key 1 to 128 bytes, no control characters. A missing key returns FG_OK and leaves *out untouched; so does a stored value that is not an integer. Set *out to a default first
fg_storage_set_int(key, value) storage.set storage.private Stores an integer. FG_QUOTA when the quota is used up or 4096 keys exist
fg_sleep_async(ms, &ticket) time.sleep none ms from 0 to 60000, otherwise FG_INVALID. Async only: the call.result with v: null arrives after the delay
fg_timer_set(id, delay_ms, repeat) timers.set none id 1 to 64 bytes; delay_ms from 1000 to 604800000 (7 days); repeat nonzero for periodic. At most 32 timers. Fires the timer event {id}, even after an unload
fg_timer_clear(id) timers.clear none Clears a timer
fg_i64 n = 0; // default for a missing key
if (fg_storage_get_int("count", &n) == FG_OK) fg_storage_set_int("count", n + 1);

Text is up to 4096 bytes of UTF-8 (bytes, not characters). The send bucket (fg_send_text*, fg_send_media, fg_edit_media) allows a burst of 5, refilled at one per second; the action bucket (fg_edit_text, fg_react) a burst of 20, refilled at five per second. A chat outside the granted scope gives FG_DENIED; a secret chat or Saved Messages gives FG_NOT_FOUND.

Function Operation Permission Notes
fg_send_text(chat, text) messages.send messages.send Plain text only. No reply, topic, silent or format; for those use fg_begin
fg_send_text_ids(chat, text, &id, &local_id) messages.send messages.send; messages.ids for the server id Like fg_send_text, plus the ids (see below). With messages.ids the call waits for the server (up to 90 s)
fg_send_media(chat, data, length, name, caption, kind, &id, &local_id) messages.send_media messages.send name 1 to 128 bytes, no / or \. caption may be NULL (sent as empty, up to 4096 bytes). kind is "auto", "file" or "voice", NULL means "auto". "voice" always returns FG_INVALID: the wrapper has no duration parameter and the host requires one for voice. The file is limited to 100 MiB by the host, but to roughly 512 KiB by the arena. Media sent by a plugin skips the message.outgoing hooks
fg_edit_text(chat, message, text) messages.edit messages.edit Only your own messages, inside Telegram’s edit window. Plain text, 1 to 4096 bytes (cannot be cleared). message is a server id or a local_id
fg_edit_media(chat, message, data, length, name, caption, kind) messages.edit_media messages.edit Replaces the file. kind is "auto" or "file" (NULL is "auto"; "voice" is FG_INVALID). The new caption replaces the old one, and NULL clears it. Counts in the send bucket. Waits up to 5 minutes
fg_react(chat, message, emoji) messages.react messages.react Sets your reaction; emoji is up to 64 bytes and must not be NULL. The wrapper cannot remove a reaction (it has no “remove” mode and no custom emoji). To remove or to use a custom emoji, send messages.react by hand without emoji (see below). Paid reactions cannot be set

fg_sent_ids(result, &id, &local_id) is the helper behind the _ids functions and can be used with any fg_send result of messages.send / messages.send_media. Either pointer may be NULL. It always writes 0 when a key is missing or null and returns FG_OK. In fg_send_text_ids and fg_send_media the out-parameters are written only when the call succeeded. local_id is a large negative number valid until the app restarts; id is the server id, present only with messages.ids; it is 0 while unknown (and for scheduled sends, and when the server did not answer in time). A local_id is accepted by fg_edit_text, fg_edit_media and fg_react.

fg_i64 id = 0, local = 0;
if (fg_send_text_ids(chat, "hello", &id, &local) == FG_OK)
fg_edit_text(chat, id ? id : local, "hello, edited");

These work only inside a message.outgoing handler (a hook declared in contributes.outgoing), need the permission messages.transform_outgoing, and are never async. Called anywhere else they return FG_INVALID; they also return FG_INVALID when no change is requested. Return 0 from fg_plugin_event after a successful edit or the edit is discarded. See Message hooks. Several calls in one handler merge; the last value of a field wins.

Function Operation Notes
fg_set_outgoing(text) messages.set_outgoing New plain text, 1 to 4096 bytes (an empty text is allowed only to clear the caption of a media send). Old entities are dropped
fg_set_outgoing_markdown(text) messages.set_outgoing Same, with format: "markdown" (**bold** and so on). Bad markup is FG_INVALID
fg_set_outgoing_html(text) messages.set_outgoing Same, with format: "html" (<b>, <i>, <a href>, <blockquote expandable>, …). See Text formatting
fg_set_outgoing_silent(silent) messages.set_outgoing Sends only silent; nonzero means send without sound. The text is left as it is
fg_drop_outgoing(reason) messages.drop_outgoing Cancels the send. reason may be NULL or empty (no toast); otherwise up to 200 bytes, shown to the user as a toast
int fg_plugin_event(fg_value event) {
if (fg_is(fg_find(event, "type"), "message.outgoing"))
return fg_set_outgoing_markdown("**hello**") == FG_OK ? 0 : -1;
return 0;
}

There are no wrappers for message.incoming (messages.set_incoming, messages.hide_incoming); call them with fg_begin.

Function Operation Permission Notes
fg_chat_info(chat, &result) chat.info chat.info result is the chat map: id, title, type, unread, pinned, archived; see Data shapes
fg_history(chat, limit, before, &result) messages.history messages.history Newest first. limit is clamped to 1 to 100. before is a message id (only older messages are returned); 0 starts from the newest. No topic or sender filter. result is an array of message objects (walk it with fg_each above); entries loaded from the server have fewer keys

The result of a big history can be large and shares the arena with everything else. Ask for a small limit.

Network traffic is governed by the user’s policy; see Network operations.

Function Operation Permission Notes
fg_fetch(url, timeout, &result) net.fetch net.https (net.local for loopback) Only url and timeout: always a GET, no headers, no body. timeout is in ms, clamped to 100 to 300000. The result is {status, headers, header_list, body}; body is a byte string, read it with a bytes reader (not fg_as_text). HTTP 4xx/5xx is a normal result. A response larger than the free arena fails with FG_TRANSPORT. For other methods use fg_begin(&w, "net.fetch", ...)
fg_tcp_connect(host, port, tls, &id) net.tcp.connect net.tcp tls nonzero for TLS (certificate checked against host). Connect timeout is the default 10 s (the wrapper cannot set it). *id is left untouched if the answer has no id
fg_udp_connect(host, port, &id) net.udp.connect net.udp A UDP socket is bound to one peer. No TLS
fg_socket_send(id, data, length) net.socket.send as for the socket data must not be empty; at most 1 MiB for TCP, 65507 bytes for UDP, and in practice about 512 KiB because of the arena. UDP: 200 datagrams per second
fg_socket_recv(id, max, timeout, &result) net.socket.recv as for the socket max clamped to 1 to 1048576, timeout to 0 to 30000 ms. result is {data: bytes, closed: bool}. A timeout is not an error: data is empty. UDP returns one datagram
fg_socket_close(id) net.socket.close as for the socket Closes it

At most 16 open sockets per kind. There are no WebSocket wrappers; use net.ws.* with fg_begin.

fg_value r = fg_none();
if (fg_fetch("https://example.org/", 5000, &r) == FG_OK) {
fg_i64 status = 0;
fg_as_int(fg_find(r, "status"), &status);
}
Function Operation Permission Notes
fg_toast(text) ui.toast ui.toast text up to 200 bytes. One toast per 2 s (FG_LIMIT)
fg_toast_action(text, id, label, undo, seconds) ui.toast ui.toast A toast with a button. id 1 to 32 bytes; arrives as toast.click {id}; toast.close {id, reason} follows. seconds is required and must be 1 to 8. label (up to 40 bytes) may be NULL or empty only with undo nonzero, which shows “Undo”; label NULL without undo gives FG_INVALID
fg_notify(title, text) notify.show notifications text 1 to 256 bytes, title up to 64 bytes; both always sent, so pass real strings. One per 5 s
fg_plugin_send_text(to, text) plugins.send plugins.message (also on the receiver) to is another plugin id (1 to 64 bytes, not yours); the receiver gets plugin.message {from, data} with data equal to the text. Up to 64 KiB. FG_NOT_FOUND for an unknown plugin

For a plugin that declares contributes.translator or contributes.aiProvider. The request comes as an event (translate.request, ai.request); answer it with the event’s ticket. See Event payloads for the request.

Function Operation Permission Notes
fg_translate_reply(ticket, items, count, markdown) translate.reply translate.provide items is an array of count strings, and count must equal the number of requested items (at most 20; each item up to 32 KiB; 96 KiB together). markdown nonzero parses every item as Markdown; otherwise plain. FG_INVALID if the count differs (you may answer again); FG_NOT_FOUND if the ticket is unknown or expired (20 s)
fg_translate_error(ticket, error) translate.reply translate.provide Fails the translation; error up to 200 bytes, non-empty
fg_ai_reply(ticket, text, markdown) ai.reply ai.provide text up to 64 KiB, non-empty. markdown nonzero for Markdown, otherwise plain. HTML is not offered by the wrapper. The ticket expires after 60 s (FG_NOT_FOUND)
fg_ai_error(ticket, error) ai.reply ai.provide Fails the request; error up to 200 bytes, non-empty
int fg_plugin_event(fg_value event) {
if (fg_is(fg_find(event, "type"), "ai.request")) {
fg_i64 ticket = 0; const char *t = 0; fg_u32 n = 0;
fg_as_int(fg_find(event, "ticket"), &ticket);
if (fg_as_text(fg_find(event, "text"), &t, &n) == 0)
fg_ai_reply(ticket, "rewritten text", 0);
}
return 0;
}

Anything in the operation list can be called with fg_begin and fg_send. Take the op name and the argument keys from the page of that operation, write exactly the right number of pairs, and read the result from v:

// messages.react with a custom emoji, as an example
fg_writer w;
fg_begin(&w, "messages.react", 3);
fg_w_text(&w, "chat"); fg_w_int(&w, chat);
fg_w_text(&w, "id"); fg_w_int(&w, message);
fg_w_text(&w, "custom_emoji"); fg_w_int(&w, 5368324170671202286LL);
int code = fg_send(&w, 0);
// an optional list: message ids for messages.delete
fg_begin(&w, "messages.delete", 3);
fg_w_text(&w, "chat"); fg_w_int(&w, chat);
fg_w_text(&w, "ids"); fg_w_array(&w, 2); fg_w_int(&w, 11); fg_w_int(&w, 12);
fg_w_text(&w, "everyone"); fg_w_bool(&w, 1);
code = fg_send(&w, 0);

Use fg_begin_async plus fg_send_async for the operations that must or may be awaited. Operations that need no arguments use fg_begin(&w, "time.now", 0). Each call is limited like a wrapper: 4096 arena bytes, one of 256 calls per event.

Telegram/plugins_sdk/c/examples/counter.c counts events in plugin storage and shows the pattern of reading, checking the code, and writing:

int fg_plugin_event(fg_value event) {
fg_i64 chat = 0;
fg_as_int(fg_find(event, "chat"), &chat);
fg_i64 count = 0;
int read = fg_storage_get_int("count", &count);
if (read != FG_OK) { fg_log("cannot read the counter"); return 100 + read; }
count += 1;
/* ... fg_storage_set_int("count", count) ... */
return (int)count;
}

Be aware of what it returns: the counter itself as the event status. That works only because the host accepts any int as a status and ignores it for ordinary events. It is not a convention to copy. A nonzero status from fg_plugin_init is logged as a start failure, and a nonzero status from a message hook discards the plugin’s edit (or refuses the message in a blocking hook). Return 0 for success and a negative number for your own errors. The example also relies on fg_storage_get_int returning FG_OK with count still at its initial 0 when the key does not exist yet.