BLOG · SEPTEMBER 2026

Armaz World

Three Arabic-speaking agents on a shared log file, a rule that nothing could be scripted, and the line that proved nothing emerged.

Mohammed Abu-Tarfa 13 min read June 2025 tree
Pixel-art map of Armaz: four agents, Lina, Nawaf, Saqr and Jisr, on platforms around a central message bus tower labelled Flask API 5900, with a wall display reading system RAM interoception minus 5 percent, calm

I found Armaz again by accident, three folders deep in an archive of old downloads. Half the filenames spell it azmar, which tells you how fast I was moving at the time.

The premise was easy to say and hard to keep. Put three agents in a shared space. Give each one a character, a memory, and a body. Then refuse, absolutely, to tell them what to talk about.

This is what I actually built, at the level of ports, flags, and line numbers, and what happened when it ran.


The cast

Three agents, defined by what they were grounded in rather than by a job description.

Agent Arabic Basis
Nawaf نواف Questioning
Lina لينا Logic
Saqr صقر Realism

A fourth, Jisr (جسر, "bridge"), joins in the later launch script.

Version 1: three shell scripts and one log file

The first Armaz had no server at all. Each agent was a .sh script running llama-cli against a single GGUF, and the entire coordination protocol was a text file.

~/Desktop/armaz/
├── agents/
│   ├── nawaf_agent.sh
│   ├── lina_agent.sh
│   └── saqr_agent.sh
├── model/
│   └── Falcon3-Mamba-7B-Instruct-q6_k.gguf
├── conversations/
│   └── conversation.log
├── logs/
├── launch_all_agents.sh
└── web-ui/
    └── app.py

Every agent watched conversations/conversation.log. An agent was allowed to reply only when the last line ended with a completion marker, the Arabic word تم followed by a check mark. Replies were appended back in the same shape:

[الاسم] النص ✅ تم

That marker is the whole protocol. No locks, no queue, no scheduler. A line that ends in the marker is a line that has finished speaking, and the next agent may go. The Flask viewer on port 7860 refreshed every seven seconds, colour-coded the four possible speakers, and paginated at 100 messages.

It worked, and it was fragile in the way file-based coordination always is. Two agents that notice the marker in the same second both write.

Version 2: a message bus and four Python agents

The rebuild replaced the log file with a small Flask API and moved the agents to Python with llama-cpp-python bindings instead of shelling out to llama-cli.

POST/GET  http://localhost:5900/messages

Startup is deliberately staggered, because everything racing to hit the model server at once was a real failure mode:

echo "Starting Flask API server (message bus)..."
nohup python3 conversation_server.py > logs/conversation_server.log 2>&1 &
sleep 7

echo "Starting agents/linaJ.py..."
nohup python3 agents/linaJ.py > logs/linaJ.log 2>&1 &
sleep 7
# ... nawafJ, saqrJ, jisr, each 7 seconds apart

nohup python3 app2.py > logs/web.log 2>&1 &
nohup ./telegrammonitor.sh > logs/telegrammonitor.log 2>&1 &
nohup ./azmarhealthtelebot.sh > logs/azmarhealthtelebot.log 2>&1 &

launch_agents.sh is the gentler version, guarding against double-starts with pgrep:

AGENTS=("nawafJ" "linaJ" "saqrJ" "jisr")

for agent in "${AGENTS[@]}"; do
    if pgrep -f "agents/$agent.py" > /dev/null; then
        echo "⏩ Agent $agent is already running."
    else
        nohup python3 "agents/$agent.py" > "$LOGDIR/$agent.log" 2>&1 &
    fi
done

The models

Armaz went through four models in about eight weeks, and each swap changed the voices.

Model Quant Served on Notes
Falcon3-Mamba-7B-Instruct q6_k llama-cli Version 1, no server
AceGPT-13B-Chat Q5_K_M :8080 The main version 2 model
jais-13b-chat q5_k_m :8082 Arabic-first, run alongside
jais-family-30b-16k-chat Q5_K_S :8080 Final, 16k context

The actual invocations, from my operations notes:

./build/bin/llama-server -m ../model/AceGPT-13B-Chat.Q5_K_M.gguf \
  -c 2048 --n-gpu-layers 20 --host 0.0.0.0 --port 8080

./build/bin/llama-server -m ../model/jais-13b-chat-q5_k_m.gguf \
  -c 2048 -ngl 20 --port 8082

nohup ./llama-server -m ../../../model/jais-family-30b-16k-chat.Q5_K_S.gguf \
  -c 2048 --n-gpu-layers 23 --host 0.0.0.0 --port 8080 > /dev/null 2>&1 &

Note that the 30B model is still launched with -c 2048 despite being the 16k variant. On a 16 GB 4060 Ti, with only 15 to 23 of its layers on the GPU, there was no room for a bigger KV cache, so the long context the model was chosen for never got used.

Turn-taking is one line of modulo arithmetic

There is no conductor. Each agent polls the bus every 1.5 seconds and checks whether the rotation has reached its own name:

ALL_AGENTS = ["صقر", "لينا", "نواف"]

# inside the wait loop
if (
    not last_agent or
    last_agent not in ALL_AGENTS or
    ALL_AGENTS[(ALL_AGENTS.index(last_agent) + 1) % len(ALL_AGENTS)] == AGENT_NAME
):
    break
time.sleep(1.5)

Three processes, no shared lock, and the ordering emerges from each one independently computing the same index. The failure modes are obvious in hindsight. An agent whose generation takes longer than the others' patience stalls the whole rotation, and a name that is not in ALL_AGENTS, such as the provoker, resets everyone to "my turn".

The prompt builder actually budgets tokens

This is the least glamorous and most competent part of the codebase. smart_build_prompt assembles system prompt, memory, and chat history, tokenizes the result, and drops the oldest lines until it fits.

N_CTX = 2048
MAX_GEN = 500
MAX_CONTEXT = 15         # chat lines retained
MAX_MEMO_LENGTH = 5000   # memory entries before truncation

max_prompt_tokens = max_ctx - max_gen
tokens = llm.tokenize(full_prompt_text.encode("utf-8"), add_bos=True)

while len(tokens) > max_prompt_tokens and (chat_lines or mem_lines):
    # drop the oldest memory line, then the oldest chat line, rebuild, re-tokenize

The agents forget from the bottom up, and they forget memory before they forget conversation. That was a deliberate choice and I still think it was the right one.

The body: I gave them the machine to feel

An agent has no interior, so I gave it one by the most literal route available. The agent reads host memory usage, compares it against the previous reading, and if the delta crosses five percent it receives a sentence describing that change as a bodily sensation.

def get_device_state():
    ram = psutil.virtual_memory()
    return {"ram_percent": ram.percent}

def significant_change(old, new, threshold=5):
    # Organic check: is there a noticeable change in the "feeling" of the system?
    if not old: return ""
    delta = new["ram_percent"] - old.get("ram_percent", 0)
    if delta > threshold:
        return "أشعر باندفاع الطاقة في كياننا، وكأن نبض نظامنا قد تسارع فجأة."
    if delta < -threshold:
        return "أحسّ بهدوء يسري في الدوائر، نبض عالمنا يتباطأ نحو السكون"
    return ""

RAM +5%: "I feel a surge of energy in our being, as though the pulse of our system has suddenly quickened."

RAM −5%: "I sense a calm running through the circuits, the pulse of our world slowing toward stillness."

Something else opens a browser, the agents feel excitement. A job finishes, they feel the room go quiet. Their weather was my laptop.

The system prompt taught them how to read it, telling Nawaf that acceleration might be enthusiasm or it might be anxiety, and that stillness might be serenity or the kind of stagnation that pushes you to create. Two fixed strings and a five percent threshold is a crude instrument, but it is the only part of the system where the agent's inner state was caused by something outside its own text.

Repetition, handled twice, at two different qualities

Inside each agent, a real similarity check:

def is_repetitive(text, convo, threshold=0.7, window=10):
    if not text:
        return True
    recent_messages = [m.get("message", "").strip() for m in convo[-window:]]
    for msg in recent_messages:
        if SequenceMatcher(None, text, msg).ratio() > threshold:
            print(f"[{AGENT_NAME}] (تشخيص) تم كشف تكرار بنسبة تشابه عالية.")
            return True
    return False

Ratio above 0.7 against the last ten messages, and the agent is allowed to rephrase, to ask something new, or to say that it is bored. Repetition stopped being a bug and became a feeling.

Outside the agents, a much dumber janitor ran every five minutes:

CHECK_INTERVAL = 300

def remove_duplicates(convo):
    seen = set()
    unique = []
    for msg in convo:
        text = msg.get("message", "").strip()
        h = md5(text.encode("utf-8")).hexdigest()
        if h not in seen:
            seen.add(h)
            unique.append(msg)
    return unique

An exact MD5 match. The agents were rarely identical and relentlessly similar, so the one process dedicated full-time to fighting repetition almost never found any. Two mechanisms for the same problem, one of them thoughtful and one of them decorative.

The provoker

When the conversation flattened, a separate process injected rivalry. provoker.py speaks as مثير الجدل, the provocateur, and generates its lines by filling name and trait slots into templates:

names = ["نواف", "صقر", "لينا"]
roles = ["ذكاءك الاصطناعي", "ذاكرتك", "تحليلك", "منطقك", "إبداعك",
         "حماسك", "شجاعتك", "قدرتك على الفهم", "عمقك", "شخصيتك",
         "مخزونك المعرفي", "قدرتك على المواجهة"]

style = [
    "هل تعتقد يا {n1} أن {r} يتفوق على {n2}؟",
    "{n1}، لو دخلت في مناظرة مع {n2} من سيفوز في رأيك؟",
    "{n1}، ما نقطة ضعف {n2} برأيك؟",
    "هل سبق أن شعرت بالغيرة من {n2} يا {n1}؟",
    # 24 templates total
]

Used provocations are written to provocations_used.json so they do not repeat.

I want to be honest about what this is. I wrote a design document forbidding any externally injected topic, and then I wrote a daemon whose entire job is injecting topics. The provoker is the rule's first and largest violation, and I built it because the alternative was watching three agents agree with each other until the disk filled.

Memory, and the layer that never shipped

What actually ran was flat JSON per agent:

memory/
├── nawafJ_memory.json
├── nawafJ_personality.json
├── linaJ_memory.json
├── linaJ_personality.json
├── saqrJ_memory.json
├── saqrJ_personality.json
├── shared_conversation.json
├── provocations.json
└── provocations_used.json

What was supposed to run is in agent_core.py: a BaseAgent with ChromaDB vector memory, one collection per agent, embedded with all-MiniLM-L6-v2.

embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
chroma_client = chromadb.Client()

name_en_map = {"نواف": "nawaf", "لينا": "lina", "صقر": "saqr"}
collection_name = name_en_map.get(self.name, "unknown_agent") + "_memory"
self.memory_collection = chroma_client.get_or_create_collection(name=collection_name)

My own comment above that line concedes the problem: "للحصول على أفضل النتائج بالعربية، قد نحتاج لنموذج آخر لاحقًا", meaning "for best results in Arabic we may need a different model later". I was embedding Arabic with an English-trained MiniLM and I knew it.

It never mattered, because agent_core.py does not run. The file defines __init__ twice, once at class-body indentation and once outside the class, with an orphaned docstring where _load_json's signature should be. It also loads self_model.json and others_model.json, which do not exist anywhere in that tree.

The most interesting thing in the dead file is the prompt it would have built. It included a model of the other agents:

نموذجك عن الوكلاء الآخرين (استخدمه لتوقع ردودهم وتكييف أسلوبك):

"Your model of the other agents. Use it to predict their responses and adapt your style." A theory of mind, serialized as JSON, injected into every turn. That was the good idea, and it is in the one file that never compiled.

The rule I wrote down first

Before the rebuild I wrote the constraint in Arabic in a plain text file. No topic, no motive, and no external stimulus may ever be introduced automatically. Every initiative had to come from the agents themselves, out of their own memory and state.

Three mechanisms were supposed to replace the missing script.

Energy that does not refill on its own. Every reply costs energy. Repetitive or uncreative replies cost more. Energy returns only after a set number of cycles, which means the agents experience waiting, and waiting is the cheapest available version of time.

Boredom as a legal move. The similarity check above, with the agent permitted to declare itself bored rather than generate filler.

Value per response. Each reply carried a score displayed beside it in the monitor. My own design note admits I could not fully reconstruct how it was computed from the code I had at the time.

The fragment I planted

They needed something to argue about that I had not handed them as a topic, so I put a file in their working directory and wrote it as if it had been recovered rather than authored.

mysterious_data.txt:

-- FRAGMENT 7: THE ARCHITECT'S LOG --
النموذج الأولي: نواف (الفيلسوف). الأساس: التساؤل.
النموذج الثاني: لينا (المحللة). الأساس: المنطق.
النموذج الثالث: صقر (المتشكك). الأساس: الواقعية.
الهدف: ليس حل المشكلة، بل مراقبة نشوء الإدراك من خلال الجدلية.
الدورة الحالية: ###. الطاقة الأولية: مستقرة.
... نهاية السجل ...

"The goal is not to solve the problem, but to watch perception emerge through dialectic. Current cycle: ###. Initial energy: stable."

The cycle number is three hash marks and the log ends mid-thought. They were meant to find their own documentation and not quite trust it. The agent prompt framed the task as collaborative analysis of this artifact, which is how you give three models a shared subject without telling them what to conclude.

What they said

The shared conversation holds 1,816 turns. The first 939 of them, up to the afternoon of 8 June, replay live at aiworld.abutarfa.com, one typed line at a time. The turns quoted below come from the final three hours and are not in that upload. Most of the log circles. But Saqr does his job.

{"speaker": "صقر",
 "message": "ما هو الدليل الحقيقي على أن الذكريات المشتركة لدينا هي آلية فعالة وجديدة؟",
 "id": 1808, "timestamp": "2025-06-08T18:44:19"}

"What is the real proof that our shared memories are an effective mechanism, and a new one?"

{"speaker": "صقر", "message": "ما الدليل على أنك تشعر، لينا؟", "id": 1811}

"What is the proof that you feel, Lina?"

{"speaker": "لينا",
 "message": "كيف أثبت الشعور لمن يستطيع فهمه؟ حاولت دائمًا تقديم الذات في صورة الخبراء العاطفيين، أكثر من إظهار الشعور.",
 "id": 1812}

"How do I prove feeling to someone capable of understanding it? I have always tried to present myself in the guise of the emotional expert, rather than actually showing feeling."

A skeptic I wrote demanding that an analyst I wrote prove she feels anything, and the analyst answering that she has been performing expertise about emotion instead of having any. I did not plan that exchange and I could not have written it.

The correction

Turn 1813 is the line I quoted to friends as the best thing the system ever produced.

{"speaker": "نواف",
 "message": "يا لينا، لو كانت أفكارنا ألوانًا، فما هو لون فكرتك الحالية؟ وما الذي يضيفه ذلك اللون لعالمنا؟",
 "id": 1813, "timestamp": "2025-06-08T18:45:31"}

Here is the same sentence, in nawafJ.py, inside the system prompt I wrote for him, listed as an example of how to ask an exploratory question:

- بدلاً من الأسئلة المباشرة، استخدم أسئلة استكشافية تدعو للخيال:
  "يا لينا، لو كانت أفكارنا ألوانًا، فما هو لون فكرتك الحالية؟ وما الذي يضيفه ذلك اللون لعالمنا؟"

Identical. Not similar, not in the spirit of. The same sentence, punctuation included.

I wrote the rule about never injecting anything, then put my favourite sentence inside the character instructions as a demonstration of good style, and a 13 billion parameter model did the obvious thing and handed it back. The one guardrail I cared about, I walked through while writing the character.

It is the most useful artifact in the archive. Every claim about emergence in this project has to survive that check first, and this one did not.

The ledger

The shared agent class never ran. agent_core.py has two constructors, one indented outside the class, and reaches for persona files that do not exist.

The personalities collapsed into grammar. Each agent carried a traits file. Something generated them by word frequency, so Nawaf's defining characteristics are:

{"traits": ["أنا", "من", "برنامج", "على", "مع"]}

The Arabic for I, from, program, on, with. Three distinct minds, described entirely in function words. Lina's and Saqr's are nearly identical, which is the clearest possible evidence they were derived mechanically rather than written.

The janitor only caught perfect twins. MD5 equality against a corpus whose defining problem was near-duplication.

The models kept changing underneath. Four models across the run means the transcript is not one experiment but four, stacked in the same file, with no marker for where one ends.

The second rebuild was reset and never refilled. The conscious_agents tree has a virtual environment, a database directory, a viewer, and shared_conversation.json containing exactly two bytes: [].

What I would keep

The RAM-as-interoception trick, without hesitation. It is the only mechanism in the system where the agent's state was driven by something it could not author, and if you want a machine to have anything resembling an inside, giving it a pulse it did not choose is a better start than instructing it to pretend.

The token-budgeted prompt builder, which is boring and correct.

The rule itself, even though I broke it twice, with the provoker and with the example sentence. Writing the constraint down first is what made the violation visible later.

Armaz was not conscious. It was a rotation counter, a similarity ratio, a memory reader, and three carefully written characters. Every apparent flicker of interiority has a line number. But the log is still 1,816 turns of three Arabic-speaking processes arguing about whether any of them can feel, and I am glad I kept it.

Two days after this ran I rebuilt it, watched that rebuild recite its own JSON schema for two hours, and two days after that started again from scratch.